71 Commits

Author SHA1 Message Date
58782f6db7 Merge branch 'development' 2023-07-01 21:49:21 +01:00
57b25cd214 Formatted DataTable date to ISO-8601 for sorting 2023-07-01 21:48:36 +01:00
666e12253e Merge branch 'development' 2023-07-01 21:33:04 +01:00
8013a776a9 Merge branch 'development' of ssh://git.vsnt.uk:2222/viveksantayana/ska-referee-test into development 2023-07-01 21:26:24 +01:00
aa1f46ee62 Bugfix: response to invalid exam codes 2023-07-01 21:24:29 +01:00
dbd8d6bbe3 Serve static files for analysis directly 2023-03-07 11:38:04 +00:00
fed46eaa1e Bugfix: stdev exception when only one test 2023-03-07 11:21:41 +00:00
79ad96a93f Added additional statistics and improvements to UI 2023-03-05 03:05:42 +00:00
ba851cb7dc Added analysis UI 2023-03-05 00:33:15 +00:00
fcc4d55947 Delete redundant lines 2023-03-05 00:32:15 +00:00
a56358b8dd Added question parser for analysis 2023-03-05 00:31:33 +00:00
179a608089 Made randomising of question order optional 2023-03-05 00:29:25 +00:00
a1289da09c Added analysis button and scripting 2023-03-05 00:28:54 +00:00
ea86fd9ae6 Updated parameter for new library version 2023-03-05 00:27:30 +00:00
76d60546e2 Cleared whitespace 2023-03-05 00:27:03 +00:00
9a02048199 Added get_file method to datasets 2023-03-05 00:26:39 +00:00
c9ad8e87cd Bugfix: rendering htm elements in results page 2023-03-04 19:48:49 +00:00
3714919ba5 Add analysis function 2023-03-04 18:56:34 +00:00
1026cc71a9 Add dataset to entry on creation 2023-03-04 18:56:10 +00:00
07fb170656 Add dataset and entry relation to database models 2023-03-04 18:55:30 +00:00
1ea93994ab Load and register analysis module blueprints 2023-03-04 18:54:47 +00:00
607b132996 Removed passing of unused values 2023-03-04 18:54:02 +00:00
7aa4d81e65 Corrected indentation 2023-03-04 18:53:02 +00:00
0ef39dcfbe Update navbars 2023-03-04 18:52:13 +00:00
e1517b89c0 Add analysis module 2023-03-04 18:51:50 +00:00
d0ed228824 Updated libraries 2023-03-04 18:49:35 +00:00
a2c52a4261 Corrected value to id property of entry 2023-02-28 20:59:12 +00:00
b2c9bdd7d2 Removed CSRF time limit 2023-02-28 20:37:23 +00:00
7536c33a48 Tidied up form. Removed help text. 2023-02-03 17:08:50 +00:00
850c2b13b7 Data use disclaimer for UI choices 2023-02-03 17:02:32 +00:00
eb69979f59 Spelling consistency advice 2023-02-03 17:02:16 +00:00
95cea46a8f Merge branch 'master' into development 2023-02-03 16:31:41 +00:00
02a1129390 Adding jquery ui css
Nesting script inside jquery function call
2023-02-03 16:26:31 +00:00
438e09f1ec Bugfix: club field selector 2023-02-03 16:15:50 +00:00
9241e1c0f7 Added club suggestion auto-complete 2023-02-03 16:06:06 +00:00
8deefb9035 Bugfix: displaying scores for incomplete entries 2023-02-02 22:44:19 +00:00
4f2984deea Bugfix: exception for incomplete entry dates 2023-02-02 22:38:49 +00:00
70d2325579 Bugfix: datetime reference 2023-02-02 22:26:19 +00:00
36d840c752 Typo 2023-02-02 22:16:29 +00:00
4400446718 Bugfix: Sorting for empty dates 2023-02-02 22:16:09 +00:00
adead30a77 Updated privacy policy 2022-11-01 08:51:03 +00:00
487f24732d Copy edit privacy notice. 2022-11-01 08:50:46 +00:00
3c06cebddf Updated credit in footer to identify maintainer. 2022-11-01 08:48:54 +00:00
d1d52fa4b6 source /home/vivek/Git/ska-referee-test/ref-test/env/bin/activateMerge branch 'development' 2022-09-13 12:05:37 +01:00
80dc8b3cff Fixed docker-compose depends_on mappings 2022-09-13 12:03:40 +01:00
a9ccd64de2 Updated dependency list 2022-09-13 11:17:17 +01:00
f5b9758bb1 Removed unused imports 2022-09-13 11:17:03 +01:00
84570d5974 Added indices to various database fields 2022-09-13 11:01:28 +01:00
edb8241ad3 Removed the word Beta from site title 2022-09-13 11:00:53 +01:00
644a539ed9 Changed to conventional extension for sqlite db 2022-09-13 11:00:07 +01:00
f05568b0de source /home/vivek/Git/ska-referee-test/ref-test/env/bin/activateMerge branch 'development' 2022-08-27 09:44:07 +01:00
da4a3e41c6 Bugfix: Wrong account password for updating user 2022-08-27 09:42:48 +01:00
77f86f7102 Bugfix: Corrected dataset name in test editor 2022-08-23 11:03:18 +01:00
358695977f Docker compose glitches 2022-08-20 18:21:52 +01:00
ddfd75c1f8 Added selecting database to Readme 2022-08-20 17:46:45 +01:00
f4642767ac Tweaking formatting of docker-compose file 2022-08-20 17:28:45 +01:00
2f729de40b mysql compose 2022-08-20 17:25:07 +01:00
d68beb938f Tweaking docker-compose 2022-08-20 17:21:21 +01:00
ca667f7896 Create database before first request 2022-08-20 16:51:13 +01:00
0cc00ef911 Updated install script to only create SQLite file 2022-08-20 16:50:34 +01:00
5ec2a86d08 Added certbot directory for nginx to serve renewal 2022-08-20 15:46:19 +01:00
cd57eca7d3 Restructure install script 2022-08-20 15:40:41 +01:00
a46338fdcb Update gitignore and dockerignore 2022-08-20 15:39:50 +01:00
40f1cebb7b Unsaved files 2022-08-20 14:58:31 +01:00
2a6478f3cf Clean up unnecessary exception imports 2022-08-20 14:53:49 +01:00
b6e250a7cd Generate random root password for MySQL 2022-08-20 14:48:56 +01:00
bcee2eedd0 Generalise exception handling 2022-08-20 14:47:46 +01:00
d9837246de Updated SQL Json support 2022-08-20 13:01:32 +01:00
62fac48904 Making logs accessible from install root 2022-08-20 13:00:09 +01:00
2bf0eeb33d Bugfix: variable definition for different actions 2022-08-20 12:59:26 +01:00
72f2af1df8 Include connection errors in exception handling 2022-08-20 12:58:47 +01:00
71 changed files with 1704 additions and 237 deletions

View File

@ -12,6 +12,7 @@ DATABASE_PORT= # Required if MySQL. Defaults to 3306
## MySQL Database Configuration (Required if configured to MySQL Database.) ## MySQL Database Configuration (Required if configured to MySQL Database.)
# Note that if using the Docker service, these configuration values will also be used when creating the database in the mysql container. # Note that if using the Docker service, these configuration values will also be used when creating the database in the mysql container.
MYSQL_RANDOM_ROOT_PASSWORD=True
MYSQL_DATABASE= # Required if MySQL. MYSQL_DATABASE= # Required if MySQL.
MYSQL_USER= # Required if MySQL MYSQL_USER= # Required if MySQL
MYSQL_PASSWORD= # Required if MySQL. Create secure password string. Note '@' character cannot be used. MYSQL_PASSWORD= # Required if MySQL. Create secure password string. Note '@' character cannot be used.

12
.gitignore vendored
View File

@ -152,4 +152,14 @@ database/data/
.encryption.key .encryption.key
# Ignore Data Dir # Ignore Data Dir
**/data/* **/data/*
# Ignore Logs Dir
logs/*
# Ignore Certbot Dir
certbot/*
# Ignore src dir (exception for robots.txt)
src/html/*
src/html/robots.txt

View File

@ -57,6 +57,16 @@ Once in the destination folder, clone all the relevant files you will need for t
(Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.) (Remember to include the trailing dot at the end, as that indicates to Git to download the files in the current directory.)
#### Choose What Database Engine You Will Use
This app is designed to use an SQLite database by default.
You can set it up to use a MySQL database by configuring the environment variables accordingly.
If your database is being hosted remotely, make sure the MySQL database has the proper authentication for the user from a remote server.
Alternatively, you can also use the second `docker-compose-mysql.yml` file which provides a MySQL database as part of the cluster.
To use the second `docker-compose-mysql.yml` file, use the following command at the last step of the installation:
```sudo docker compose -f docker-compose-mysql.yml up```
#### Populate Environment Variables #### Populate Environment Variables
Configuration values for the app are stored in the environment variables file. Configuration values for the app are stored in the environment variables file.

2
certbot/.gitignore vendored
View File

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

90
docker-compose-mysql.yml Normal file
View File

@ -0,0 +1,90 @@
version: '3.9'
volumes:
app:
mysql:
services:
nginx:
container_name: reftest_server
image: nginx:alpine
volumes:
- ./certbot:/etc/letsencrypt:ro
- ./nginx:/etc/nginx
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
ports:
- 80:80
- 443:443
restart: unless-stopped
networks:
- frontend
depends_on:
- app
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
app:
container_name: reftest_app
image: reftest
build: ./ref-test
env_file:
- ./.env
ports:
- 5000
volumes:
- app:/ref-test/data
- ./logs:/ref-test/data/logs
restart: unless-stopped
networks:
- frontend
- backend
depends_on:
postfix:
mysql:
condition: service_healthy
postfix:
container_name: reftest_postfix
image: catatnight/postfix:latest
restart: unless-stopped
env_file:
- ./.env
ports:
- 25
networks:
- backend
certbot:
container_name: reftest_certbot
image: certbot/certbot
volumes:
- ./certbot:/etc/letsencrypt
- ./src/html/certbot:/var/www/html
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
mysql:
container_name: reftest_db
image: mysql:8.0
env_file:
- ./.env
volumes:
- mysql:/var/lib/mysql
ports:
- 3306
networks:
- backend
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 10s
retries: 10
networks:
frontend:
external: false
backend:
external: false

View File

@ -1,7 +1,7 @@
version: '3.9' version: '3.9'
volumes: volumes:
data: app:
services: services:
nginx: nginx:
@ -10,12 +10,14 @@ services:
volumes: volumes:
- ./certbot:/etc/letsencrypt:ro - ./certbot:/etc/letsencrypt:ro
- ./nginx:/etc/nginx - ./nginx:/etc/nginx
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro - ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
- ./ref-test/app/root:/usr/share/nginx/html/root:ro - ./ref-test/app/root:/usr/share/nginx/html/root:ro
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro - ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro - ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro - ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro - ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
- ./ref-test/app/analysis/static:/usr/share/nginx/html/analysis/static:ro
ports: ports:
- 80:80 - 80:80
- 443:443 - 443:443
@ -35,7 +37,8 @@ services:
ports: ports:
- 5000 - 5000
volumes: volumes:
- data:/ref-test/data - app:/ref-test/data
- ./logs:/ref-test/data/logs
restart: unless-stopped restart: unless-stopped
networks: networks:
- frontend - frontend
@ -59,7 +62,7 @@ services:
image: certbot/certbot image: certbot/certbot
volumes: volumes:
- ./certbot:/etc/letsencrypt - ./certbot:/etc/letsencrypt
- ./src/html:/var/www/html - ./src/html/certbot:/var/www/html
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
networks: networks:

View File

@ -1,6 +1,6 @@
# Certbot Renewal # Certbot Renewal
location ^~ /.well-known/acme-challenge/ { location ^~ /.well-known/acme-challenge/ {
root /usr/share/nginx/html; root /usr/share/nginx/html/certbot;
allow all; allow all;
default_type "text/plain"; default_type "text/plain";
} }

View File

@ -45,6 +45,11 @@ server {
alias /usr/share/nginx/html/view/static/; alias /usr/share/nginx/html/view/static/;
} }
location ^~ /admin/analysis/static/ {
include /etc/nginx/mime.types;
alias /usr/share/nginx/html/analysis/static/;
}
# Proxy to the main app for all other requests # Proxy to the main app for all other requests
location / { location / {
include /etc/nginx/conf.d/proxy_headers.conf; include /etc/nginx/conf.d/proxy_headers.conf;

View File

@ -1,2 +1,3 @@
env/ env/
__pycache__/ __pycache__/
data/

View File

@ -1,5 +1,5 @@
from .config import Production as Config from .config import Production as Config
from .models import User from .models import *
from .extensions import bootstrap, csrf, db, login_manager, mail from .extensions import bootstrap, csrf, db, login_manager, mail
from .tools.logs import write from .tools.logs import write
@ -7,7 +7,6 @@ from flask import flash, Flask, render_template, request
from flask.helpers import abort, url_for from flask.helpers import abort, url_for
from flask.json import jsonify from flask.json import jsonify
from flask_wtf.csrf import CSRFError from flask_wtf.csrf import CSRFError
from sqlalchemy.exc import SQLAlchemyError
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from datetime import datetime from datetime import datetime
@ -27,7 +26,7 @@ def create_app():
@login_manager.user_loader @login_manager.user_loader
def _load_user(id): def _load_user(id):
try: return User.query.filter_by(id=id).first() try: return User.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when loading user fo login manager: {exception}') write('system.log', f'Database error when loading user fo login manager: {exception}')
return abort(500) return abort(500)
@ -52,6 +51,7 @@ def create_app():
from .views import views from .views import views
from .editor.views import editor from .editor.views import editor
from .view.views import view from .view.views import view
from .analysis.views import analysis
app.register_blueprint(admin, url_prefix='/admin') app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(api, url_prefix='/api') app.register_blueprint(api, url_prefix='/api')
@ -59,5 +59,12 @@ def create_app():
app.register_blueprint(quiz) app.register_blueprint(quiz)
app.register_blueprint(editor, url_prefix='/admin/editor') app.register_blueprint(editor, url_prefix='/admin/editor')
app.register_blueprint(view, url_prefix='/admin/view') app.register_blueprint(view, url_prefix='/admin/view')
app.register_blueprint(analysis, url_prefix='/admin/analysis')
"""Create Database Tables before First Request"""
@app.before_first_request
def _create_database_tables():
with app.app_context():
db.create_all()
return app return app

View File

@ -88,6 +88,19 @@ $('.test-action').click(function(event) {
}) })
} else if (action == 'edit') { } else if (action == 'edit') {
window.location.href = `/admin/test/${id}/` window.location.href = `/admin/test/${id}/`
} else if (action == 'analyse') {
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'test'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
} }
event.preventDefault() event.preventDefault()
@ -123,6 +136,19 @@ $('.edit-question-dataset').click(function(event) {
window.location.href = `/admin/view/${id}` window.location.href = `/admin/view/${id}`
} else if (action == 'download') { } else if (action == 'download') {
window.location.href = `/admin/settings/questions/download/${id}/` window.location.href = `/admin/settings/questions/download/${id}/`
} else if (action == 'analyse') {
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'dataset'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
} }
} }
event.preventDefault() event.preventDefault()

View File

@ -50,7 +50,7 @@
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5> <h5 class="mb-1">Start Time</h5>
</div> </div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }} {{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
</li> </li>
<li class="list-group-item list-group-item-action"> <li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
@ -59,7 +59,7 @@
<span class="badge bg-danger">Late</span> <span class="badge bg-danger">Late</span>
{% endif %} {% endif %}
</div> </div>
{{ entry.end_time.strftime('%d %b %Y %H:%M:%S') }} {{ entry.end_time.strftime('%d %b %Y %H:%M:%S') if entry.end_time else None }}
</li> </li>
<li class="list-group-item list-group-item-action"> <li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">

View File

@ -1,2 +1,2 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p> <p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>

View File

@ -1,6 +1,6 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark"> <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container"> <div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a> <a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<button <button
class="navbar-toggler" class="navbar-toggler"
type="button" type="button"
@ -20,8 +20,28 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results"> <li class="nav-item dropdown" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a> <a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li> </li>
<li class="nav-item dropdown" id="nav-tests"> <li class="nav-item dropdown" id="nav-tests">
<a <a
@ -36,7 +56,7 @@
</a> </a>
<ul <ul
class="dropdown-menu" class="dropdown-menu"
aria-labelledby="dropdown-settings" aria-labelledby="dropdown-tests"
> >
<li> <li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a> <a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
@ -58,7 +78,7 @@
<li class="nav-item dropdown" id="nav-settings"> <li class="nav-item dropdown" id="nav-settings">
<a <a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
id="dropdown-account" id="dropdown-settings"
role="button" role="button"
href="{{ url_for('admin._settings') }}" href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"

View File

@ -28,7 +28,7 @@
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a> <a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
</td> </td>
<td> <td>
{{ test.end_date.strftime('%d %b %Y') }} {{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -72,10 +72,14 @@
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a> <a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
</td> </td>
<td> <td>
{{ result.end_time.strftime('%d %b %Y %H:%M') }} {{ result.end_time.strftime('%d %b %Y %H:%M') if result.end_time else None }}
</td> </td>
<td> <td>
{{ (100*result.result['score']/result.result['max'])|round|int }}&percnt; ({{ result.result.grade }}) {% if result.result %}
{{ (100*result.result['score']/result.result['max'])|round|int }}&percnt; ({{ result.result.grade }})
{% else %}
Incomplete
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -117,7 +121,7 @@
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a> <a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
</td> </td>
<td> <td>
{{ test.end_date.strftime('%d %b %Y') }} {{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -138,7 +142,7 @@
<div class="card m-3"> <div class="card m-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Help</h5> <h5 class="card-title">Help</h5>
<p class="card-text">This web app was developed by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p> <p class="card-text">This web app was developed and is maintained by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
<a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a> <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues" class="btn btn-primary">Open an Issue</a>
</div> </div>
</div> </div>

View File

@ -49,7 +49,7 @@
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Time</h5> <h5 class="mb-1">Start Time</h5>
</div> </div>
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }} {{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
</li> </li>
{% endif %} {% endif %}
<li class="list-group-item list-group-item-action"> <li class="list-group-item list-group-item-action">
@ -108,7 +108,7 @@
{% for tag, scores in entry.result.tags.items() %} {% for tag, scores in entry.result.tags.items() %}
<tr> <tr>
<td> <td>
{{ tag }} {{ tag|safe }}
</td> </td>
<td> <td>
{{ scores.scored }} {{ scores.scored }}

View File

@ -54,7 +54,7 @@
</td> </td>
<td> <td>
{% if entry.end_time %} {% if entry.end_time %}
{{ entry.end_time.strftime('%d %b %Y') }} {{ entry.end_time.strftime('%Y-%m-%d %H:%M') }}
{% endif %} {% endif %}
</td> </td>
<td> <td>

View File

@ -43,7 +43,7 @@
{{ element.get_name() }} {{ element.get_name() }}
</td> </td>
<td> <td>
{{ element.date.strftime('%d %b %Y %H:%M') }} {{ element.date.strftime('%Y-%m-%d %H:%M') }}
</td> </td>
<td> <td>
{{ element.creator.get_username() }} {{ element.creator.get_username() }}
@ -52,6 +52,15 @@
{{ element.tests|length }} {{ element.tests|length }}
</td> </td>
<td class="row-actions"> <td class="row-actions">
<a
href="javascript:void(0)"
class="btn btn-success edit-question-dataset {% if not element.entries %} disabled {% endif %}"
data-id="{{ element.id }}"
data-action="analyse"
title="Analyse Answers"
>
<i class="bi bi-search button-icon"></i>
</a>
<a <a
href="javascript:void(0)" href="javascript:void(0)"
class="btn btn-primary edit-question-dataset" class="btn btn-primary edit-question-dataset"
@ -63,7 +72,7 @@
</a> </a>
<a <a
href="javascript:void(0)" href="javascript:void(0)"
class="btn btn-primary view-question-dataset" class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}" data-id="{{ element.id }}"
data-action="view" data-action="view"
title="View Questions" title="View Questions"

View File

@ -19,7 +19,7 @@
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Dataset</h5> <h5 class="mb-1">Dataset</h5>
</div> </div>
{{ test.dataset.date.strftime('%Y%m%d%H%M%S') }} <a href="{{ url_for('view._view_console', id=test.dataset.id) }}">{{ test.dataset.get_name() }}</a>
</li> </li>
<li class="list-group-item list-group-item-action"> <li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
@ -32,13 +32,13 @@
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Start Date</h5> <h5 class="mb-1">Start Date</h5>
</div> </div>
{{ test.start_date.strftime('%d %b %Y %H:%M') }} {{ test.start_date.strftime('%d %b %Y %H:%M') if test.start_date else None }}
</li> </li>
<li class="list-group-item list-group-item-action"> <li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">Expiry Date</h5> <h5 class="mb-1">Expiry Date</h5>
</div> </div>
{{ test.end_date.strftime('%d %b %Y %H:%M') }} {{ test.end_date.strftime('%d %b %Y %H:%M') if test.end_date else None }}
</li> </li>
<li class="list-group-item list-group-item-action"> <li class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between"> <div class="d-flex w-100 justify-content-between">
@ -71,7 +71,7 @@
{% for entry in test.entries %} {% for entry in test.entries %}
<tr> <tr>
<td> <td>
<a href="{{ url_for('admin._view_entry', id=entry) }}" >Entry {{ loop.index }}</a> <a href="{{ url_for('admin._view_entry', id=entry.id) }}" >Entry {{ loop.index }}</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -162,7 +162,7 @@
{% include "admin/components/client-alerts.html" %} {% include "admin/components/client-alerts.html" %}
</div> </div>
<div class="container justify-content-center"> <div class="container justify-content-center">
<div class="row"> <div class="my-3 row">
{% if test.start_date <= now %} {% if test.start_date <= now %}
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}"> <a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
<i class="bi bi-hourglass-bottom button-icon"></i> <i class="bi bi-hourglass-bottom button-icon"></i>
@ -174,6 +174,16 @@
Start Exam Start Exam
</a> </a>
{% endif %} {% endif %}
<a
href="#"
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
data-id="{{test.id}}"
title="Analyse Exam"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
Analyse Exam
</a>
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}"> <a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
<i class="bi bi-file-earmark-excel-fill button-icon"></i> <i class="bi bi-file-earmark-excel-fill button-icon"></i>
Delete Exam Delete Exam

View File

@ -33,13 +33,13 @@
{% for test in tests %} {% for test in tests %}
<tr class="table-row"> <tr class="table-row">
<td> <td>
{{ test.start_date.strftime('%d %b %y %H:%M') }} {{ test.start_date.strftime('%Y-%m-%d %H:%M') }}
</td> </td>
<td> <td>
{{ test.get_code() }} {{ test.get_code() }}
</td> </td>
<td> <td>
{{ test.end_date.strftime('%d %b %Y %H:%M') }} {{ test.end_date.strftime('%Y-%m-%d %H:%M') }}
</td> </td>
<td> <td>
{% if test.time_limit == None -%} {% if test.time_limit == None -%}
@ -58,6 +58,15 @@
{{ test.entries|length }} {{ test.entries|length }}
</td> </td>
<td class="row-actions"> <td class="row-actions">
<a
href="#"
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
data-id="{{test.id}}"
title="Analyse Exam"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
</a>
<a <a
href="#" href="#"
class="btn btn-primary test-action" class="btn btn-primary test-action"

View File

@ -9,9 +9,8 @@ from ..tools.test import answer_options, get_correct_answers
from flask import abort, Blueprint, jsonify, render_template, request, send_file, session from flask import abort, Blueprint, jsonify, render_template, request, send_file, session
from flask.helpers import abort, flash, redirect, url_for from flask.helpers import abort, flash, redirect, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from sqlalchemy.exc import SQLAlchemyError
from datetime import date, datetime, timedelta from datetime import date, datetime, MINYEAR, timedelta
from json import loads from json import loads
from os import path from os import path
import secrets import secrets
@ -31,15 +30,15 @@ def _home():
try: try:
tests = Test.query.all() tests = Test.query.all()
results = Entry.query.all() results = Entry.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ] current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
current_tests.sort(key= lambda x: x.end_date, reverse=True) current_tests.sort(key= lambda x: x.end_date or datetime(MINYEAR,1,1), reverse=True)
upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()] upcoming_tests = [ test for test in tests if test.start_date.date() > datetime.now().date()]
upcoming_tests.sort(key= lambda x: x.start_date) upcoming_tests.sort(key= lambda x: x.start_date or datetime(MINYEAR,1,1))
recent_results = [result for result in results if not result.status == 'started' ] recent_results = [result for result in results if not result.status == 'started' ]
recent_results.sort(key= lambda x: x.end_time, reverse=True) recent_results.sort(key= lambda x: x.end_time or datetime(MINYEAR,1,1), reverse=True)
return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results) return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results)
@admin.route('/settings/') @admin.route('/settings/')
@ -48,7 +47,7 @@ def _settings():
try: try:
users = User.query.all() users = User.query.all()
datasets = Dataset.query.all() datasets = Dataset.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
return render_template('/admin/settings/index.html', users=users, datasets=datasets) return render_template('/admin/settings/index.html', users=users, datasets=datasets)
@ -61,7 +60,7 @@ def _login():
if request.method == 'POST': if request.method == 'POST':
if form.validate_on_submit(): if form.validate_on_submit():
try: users = User.query.all() try: users = User.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
user = None user = None
@ -113,7 +112,7 @@ def _reset():
if form.validate_on_submit(): if form.validate_on_submit():
user = None user = None
try: users = User.query.all() try: users = User.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
for _user in users: for _user in users:
@ -128,7 +127,7 @@ def _reset():
token = request.args.get('token') token = request.args.get('token')
if token: if token:
try: user = User.query.filter_by(reset_token=token).first() try: user = User.query.filter_by(reset_token=token).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not user: return redirect(url_for('admin._reset')) if not user: return redirect(url_for('admin._reset'))
@ -148,7 +147,7 @@ def _update_password():
if form.validate_on_submit(): if form.validate_on_submit():
user = session.pop('user') user = session.pop('user')
try: user = User.query.filter_by(id=user).first() try: user = User.query.filter_by(id=user).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
user.update(password=request.form.get('password')) user.update(password=request.form.get('password'))
@ -162,7 +161,7 @@ def _update_password():
def _users(): def _users():
form = CreateUser() form = CreateUser()
try: users = User.query.all() try: users = User.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if request.method == 'POST': if request.method == 'POST':
@ -182,7 +181,7 @@ def _users():
@login_required @login_required
def _delete_user(id:str): def _delete_user(id:str):
try: user = User.query.filter_by(id=id).first() try: user = User.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
form = DeleteUser() form = DeleteUser()
@ -209,14 +208,14 @@ def _delete_user(id:str):
@login_required @login_required
def _update_user(id:str): def _update_user(id:str):
try: user = User.query.filter_by(id=id).first() try: user = User.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
form = UpdateUser() form = UpdateUser()
if request.method == 'POST': if request.method == 'POST':
if not user: return jsonify({'error': 'User does not exist.'}), 400 if not user: return jsonify({'error': 'User does not exist.'}), 400
if form.validate_on_submit(): if form.validate_on_submit():
if not user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401 if not current_user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
success, message = user.update( success, message = user.update(
password = request.form.get('password'), password = request.form.get('password'),
email = request.form.get('email'), email = request.form.get('email'),
@ -252,9 +251,8 @@ def _questions():
if success: return jsonify({'success': message}), 200 if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
return send_errors_to_client(form=form) return send_errors_to_client(form=form)
try: data = Dataset.query.all() try: data = Dataset.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
return render_template('/admin/settings/questions.html', form=form, data=data) return render_template('/admin/settings/questions.html', form=form, data=data)
@ -266,7 +264,7 @@ def _edit_questions():
action = request.get_json()['action'] action = request.get_json()['action']
if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400 if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400
try: dataset = Dataset.query.filter_by(id=id).first() try: dataset = Dataset.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if action == 'delete': success, message = dataset.delete() if action == 'delete': success, message = dataset.delete()
@ -277,12 +275,12 @@ def _edit_questions():
@login_required @login_required
def _download(id:str): def _download(id:str):
try: dataset = Dataset.query.filter_by(id=id).first() try: dataset = Dataset.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not dataset: return abort(404) if not dataset: return abort(404)
data_path = path.abspath(dataset.get_file()) data_path = path.abspath(dataset.get_file())
return send_file(data_path, as_attachment=True, attachment_filename=f'{dataset.get_name()}.json') return send_file(data_path, as_attachment=True, download_name=f'{dataset.get_name()}.json')
@admin.route('/tests/<string:filter>/', methods=['GET']) @admin.route('/tests/<string:filter>/', methods=['GET'])
@admin.route('/tests/', methods=['GET']) @admin.route('/tests/', methods=['GET'])
@ -291,7 +289,7 @@ def _download(id:str):
def _tests(filter:str=None): def _tests(filter:str=None):
tests = None tests = None
try: _tests = Test.query.all() try: _tests = Test.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
form = None form = None
@ -310,7 +308,7 @@ def _tests(filter:str=None):
if filter in [None, '', 'active']: if filter in [None, '', 'active']:
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ] tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
display_title = 'Active Exams' display_title = 'Active Exams'
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.' error_none = 'There are no exams that are currently active. You can create one using the Create Exam form.'
if filter == 'expired': if filter == 'expired':
tests = [ test for test in _tests if test.end_date < now ] tests = [ test for test in _tests if test.end_date < now ]
display_title = 'Expired Exams' display_title = 'Expired Exams'
@ -340,7 +338,7 @@ def _create_test():
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit')) new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
dataset = request.form.get('dataset') dataset = request.form.get('dataset')
try: new_test.dataset = Dataset.query.filter_by(id=dataset).first() try: new_test.dataset = Dataset.query.filter_by(id=dataset).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
success, message = new_test.create() success, message = new_test.create()
@ -357,7 +355,7 @@ def _edit_test():
action = request.get_json()['action'] action = request.get_json()['action']
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400 if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
try: test = Test.query.filter_by(id=id).first() try: test = Test.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404 if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
@ -374,7 +372,7 @@ def _edit_test():
def _view_test(id:str=None): def _view_test(id:str=None):
form = AddTimeAdjustment() form = AddTimeAdjustment()
try: test = Test.query.filter_by(id=id).first() try: test = Test.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if request.method == 'POST': if request.method == 'POST':
@ -394,7 +392,7 @@ def _view_test(id:str=None):
@login_required @login_required
def _delete_adjustment(id:str=None): def _delete_adjustment(id:str=None):
try: test = Test.query.filter_by(id=id).first() try: test = Test.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not test: return jsonify({'error': 'Invalid test ID.'}), 404 if not test: return jsonify({'error': 'Invalid test ID.'}), 404
@ -407,7 +405,7 @@ def _delete_adjustment(id:str=None):
@login_required @login_required
def _view_entries(): def _view_entries():
try: entries = Entry.query.all() try: entries = Entry.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
return render_template('/admin/results.html', entries = entries) return render_template('/admin/results.html', entries = entries)
@ -416,7 +414,7 @@ def _view_entries():
@login_required @login_required
def _view_entry(id:str=None): def _view_entry(id:str=None):
try: entry = Entry.query.filter_by(id=id).first() try: entry = Entry.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if request.method == 'POST': if request.method == 'POST':
@ -436,10 +434,7 @@ def _view_entry(id:str=None):
flash('Invalid entry ID.', 'error') flash('Invalid entry ID.', 'error')
return redirect(url_for('admin._view_entries')) return redirect(url_for('admin._view_entries'))
test = entry.test test = entry.test
dataset = test.dataset data = test.dataset.get_data()
dataset_path = dataset.get_file()
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
correct = get_correct_answers(dataset=data) correct = get_correct_answers(dataset=data)
answers = answer_options(dataset=data) answers = answer_options(dataset=data)
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers) return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
@ -450,7 +445,7 @@ def _generate_certificate():
from ..extensions import db from ..extensions import db
id = request.get_json()['id'] id = request.get_json()['id']
try: entry = Entry.query.filter_by(id=id).first() try: entry = Entry.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404 if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404

View File

View File

@ -0,0 +1,8 @@
#alert-box {
margin: 30px auto;
max-width: 460px;
}
.cell-percentage::after {
content: '%';
}

View File

@ -0,0 +1,260 @@
body {
padding: 80px 0;
}
.site-footer {
background-color: lightgray;
font-size: small;
}
.site-footer p {
margin: 0;
}
.form-container {
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}
.form-display {
width: 100%;
max-width: 420px;
padding: 15px;
margin: auto;
}
.form-heading {
margin-bottom: 2rem;
}
.form-label-group {
position: relative;
margin-bottom: 2rem;
}
.form-label-group input,
.form-label-group label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
}
.form-label-group label {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.form-label-group input {
background-color: transparent;
border: none;
border-radius: 0%;
border-bottom: 2px solid #585858;
}
.form-label-group input:active, .form-label-group input:focus {
background-color: transparent;
}
.form-label-group input::-webkit-input-placeholder {
color: transparent;
}
.form-label-group input:-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-moz-placeholder {
color: transparent;
}
.form-label-group input::placeholder {
color: transparent;
}
.form-label-group input:not(:placeholder-shown) {
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
padding-bottom: calc(var(--input-padding-y) / 3);
}
.form-label-group input:not(:placeholder-shown) ~ label {
padding-top: calc(var(--input-padding-y) / 3);
padding-bottom: calc(var(--input-padding-y) / 3);
font-size: 12px;
color: #777;
}
.form-check {
margin-bottom: 2rem;
}
.checkbox input {
transform: scale(1.5);
margin-right: 1rem;
}
.signin-forgot-password {
font-size: 14pt;
}
.form-submission-button {
margin-bottom: 2rem;
}
.form-submission-button button, .form-submission-button a {
margin: 1rem;
vertical-align: middle;
}
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
margin: 0 2px;
}
table.dataTable {
border-collapse: collapse;
width: 100%;
}
.table-row {
vertical-align: middle;
}
.row-actions {
text-align: center;
white-space: nowrap;
}
.dataTables_wrapper .dt-buttons {
left: 50%;
transform: translateX(-50%);
float:none;
text-align:center;
}
.row-actions button, .row-actions a {
margin: 0px 5px;
}
#cookie-alert {
padding-right: 16px;
}
#dismiss-cookie-alert {
margin-top: 16px;
width: fit-content;
}
.alert-db-empty {
width: 100%;
max-width: 720px;
font-size: 14pt;
margin: 20px auto;
}
.form-date-input, .form-select-input {
position: relative;
margin: 2rem 0;
}
.form-date-input input,
.form-date-input label, .form-select-input select, .form-select-input label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
width: 100%;
background-color: transparent;
border: none;
border-bottom: 2px solid #585858;
}
.datepicker::-webkit-calendar-picker-indicator {
border: 1px;
border-color: gray;
border-radius: 10%;
}
.form-date-input label, .form-select-input label {
/* position: absolute; */
/* top: 0;
left: 0; */
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.button-icon {
font-size: 20px;
}
.form-upload {
margin: 2rem 0;
font-size: 14pt;
}
.result-action-buttons, .test-action {
margin: 5px auto;
width: fit-content;
}
.accordion-item {
background-color: unset;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
/* Fallback for Edge
-------------------------------------------------- */
@supports (-ms-ime-align: auto) {
.form-label-group label {
display: none;
}
.form-label-group input::-ms-input-placeholder {
color: #777;
}
}
/* Fallback for IE
-------------------------------------------------- */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.form-label-group label {
display: none;
}
.form-label-group input:-ms-input-placeholder {
color: #777;
}
}

View File

@ -0,0 +1,27 @@
// Analyse Button Listener
$('.button-analyse').click(function(event) {
let buttonClass = $(this).data('class')
let id = null
if (buttonClass == 'test' ) {
id = $('#select-test').children('option:selected').val()
} else if (buttonClass == 'dataset' ) {
id = $('#select-dataset').children('option:selected').val()
}
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': buttonClass}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
event.preventDefault()
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,115 @@
// Menu Highlight Scripts
const menuItems = document.getElementsByClassName('nav-link')
for(let i = 0; i < menuItems.length; i++) {
if(menuItems[i].pathname == window.location.pathname) {
menuItems[i].classList.add('active')
}
}
const dropdownItems = document.getElementsByClassName('dropdown-item')
for(let i = 0; i< dropdownItems.length; i++) {
if(dropdownItems[i].pathname == window.location.pathname) {
dropdownItems[i].classList.add('active')
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
}
}
// General Post Method Form Processing Script
$('form.form-post').submit(function(event) {
var $form = $(this)
var data = $form.serialize()
var url = $(this).prop('action')
var rel_success = $(this).data('rel-success')
$.ajax({
url: url,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.redirect_to) {
window.location.href = response.redirect_to
}
else {
window.location.href = rel_success
}
},
error: function(response) {
error_response(response)
}
})
event.preventDefault()
})
function error_response(response) {
const $alert = $("#alert-box")
$alert.html('')
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`)
} else if (response.responseJSON.error instanceof Array) {
var output = ''
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
$alert.html(output)
}
}
$alert.focus()
}
// Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
console.log(response)
},
error: function(response){
console.log(response)
}
})
event.preventDefault()
})
// Create New Dataset
$('.create-new-dataset').click(function(event){
$.ajax({
url: '/api/editor/new/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
if (response.redirect_to) {
window.location.href = response.redirect_to
}
},
error: function(response){
console.log(response)
}
})
event.preventDefault()
})

View File

@ -0,0 +1,198 @@
{% extends "analysis/components/datatable.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/analysis.css') }}"
/>
{% endblock %}
{% block content %}
<h1>Analysis by {{ type[0]|upper }}{{ type[1:] }}</h1>
<div class="container">
<p class="lead">
The analysis section displays statistics for all test results as well as answers to individual questions.
Analysis reports can be generated per exam or per question dataset to identify common mistakes or patterns in answers.
</p>
<div class="input-group mb-3">
<span class="input-group-text">
{% if type == 'exam' %}
Exam Code
{% elif type == 'dataset' %}
Dataset Name
{% endif %}
</span>
<span class="form-control">
{{ subject }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Total Entries</span>
<span class="form-control">
{{ analysis.entries }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Passed</span>
<span class="form-control">
{{ analysis.grades.merit + analysis.grades.pass }} ({{ ((analysis.grades.merit + analysis.grades.pass)*100/analysis.entries)|round(2) }} &percnt;)
</span>
</div>
<div class="mb-3">
<span class="badge rounded-pill progress-bar-striped bg-success">Merit: {{ analysis.grades.merit }}</span> <span class="badge rounded-pill bg-primary progress-bar-striped">Pass: {{ analysis.grades.pass }}</span> <span class="badge rounded-pill progress-bar-striped bg-danger">Fail: {{ analysis.grades.fail }}</span>
<div class="my-1 progress">
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" style="width: {{ (analysis.grades.merit*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.merit }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.merit*100/analysis.entries)|round(2) }} &percnt;</div>
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{ (analysis.grades.pass*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.pass }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.pass*100/analysis.entries)|round(2) }} &percnt;</div>
<div class="progress-bar progress-bar-striped bg-danger" role="progressbar" style="width: {{ (analysis.grades.fail*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.fail }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.fail*100/analysis.entries)|round(2) }} &percnt;</div>
</div>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Mean Score</span>
<span class="form-control">
{{ analysis.scores.mean|round(2) }} &percnt;
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Standard Deviation</span>
<span class="form-control">
{% if analysis.scores.stdev %}
{{ analysis.scores.stdev|round(2) }}
{% else %}
{{ None }}
{% endif %}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Median Score</span>
<span class="form-control">
{{ analysis.scores.median|round(2) }} &percnt;
</span>
</div>
{% if type == 'exam' %}
<div class="input-group mb-3">
<span class="input-group-text">Dataset Name</span>
<span class="form-control">
{{ dataset.get_name() }}
</span>
</div>
{% endif %}
</div>
<div class="container">
<table id="analysis-table" class="table table-striped" style="width:100%">
<thead>
<th data-priority="1">
Question
</th>
<th data-priority="1">
Percent Correct
</th>
<th data-priority="2">
Answers
</th>
<th data-priority="3">
Tags
</th>
</thead>
<tbody>
{% for question in questions %}
<tr class="table-row">
<td>
{{ question.q_no + 1 }}
</td>
<td class="cell-percentage">
{{ ((analysis.answers[question.q_no][question.correct] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}
</td>
<td>
<table style="width:100%">
{% for option in question.options %}
<tr>
<td style="width:50%">
{{ option[1] }}
</td>
<td>
{% if question.correct == option[0] %}
<div class="progress">
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
</div>
{% else %}
<div class="progress">
<div class="progress-bar bg-danger progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
<td>
<ul>
{% for tag in question.tags %}
<li>{{ tag|safe }}</li>
{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
{% block script %}
<script>
const target = "{{ url_for('api._editor') }}"
const id = "{{ dataset.id }}"
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/analysis.js') }}"
></script>
{% endblock %}
{% block custom_data_script %}
<script>
console.log($('#analysis-table'))
$(document).ready(function() {
$('#analysis-table').DataTable({
'searching': true,
'columnDefs': [
{'sortable': true, 'targets': [0,1]},
{'sortable': false, 'targets': [2,3]},
{'searchable': true, 'targets': [0,2,3]}
],
'order': [[0, 'asc'], [1, 'desc']],
'buttons': [
{
extend: 'print',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'excel',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'pdf',
exportOptions: {
columns: [0, 1, 2, 3]
}
}
],
'responsive': 'true',
'colReorder': 'true',
'fixedHeader': 'true',
'searchBuilder': {
depthLimit: 2,
columns: [2, 3],
},
dom: 'BQlfrtip'
});
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
} );
$('#analysis-table').show();
$(window).trigger('resize');
</script>
{% endblock %}

View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/style.css') }}"
/>
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/view.css') }}"
/>
{% block style %}
{% endblock %}
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
{% include "analysis/components/og-meta.html" %}
</head>
<body class="bg-light">
{% block navbar %}
{% include "analysis/components/navbar.html" %}
{% endblock %}
<div class="container">
{% block top_alerts %}
{% include "analysis/components/server-alerts.html" %}
{% endblock %}
{% block content %}{% endblock %}
</div>
<footer class="container site-footer mt-5">
{% block footer %}
{% include "analysis/components/footer.html" %}
{% endblock %}
</footer>
<!-- JQuery, Popper, and Bootstrap js dependencies -->
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous">
</script>
<script>
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
</script>
<script
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
crossorigin="anonymous">
</script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous"
></script>
<!-- Custom js -->
<script type="text/javascript">
var csrf_token = "{{ csrf_token() }}";
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
}
}
});
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/script.js') }}"
></script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/analysis.js') }}"
></script>
{% block script %}
{% endblock %}
{% block datatable_scripts %}
{% endblock %}
{% block custom_data_script %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1 @@
<div id="alert-box" tabindex="-1"></div>

View File

@ -0,0 +1,28 @@
{% extends "analysis/components/base.html" %}
{% block datatable_css %}
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
{% endblock %}
{% block datatable_scripts %}
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
{% endblock %}

View File

@ -0,0 +1,2 @@
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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>

View File

@ -0,0 +1,4 @@
{% extends "analysis/components/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block top_alerts %}
{% endblock %}

View File

@ -0,0 +1,137 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar"
aria-controls="navbar"
aria-expanded="false"
aria-label="Toggle Navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbar">
<ul class="navbar-nav">
{% if not current_user.is_authenticated %}
<li class="nav-item" id="nav-login">
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item dropdown" id="nav-results">
<a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-tests">
<a
class="nav-link dropdown-toggle"
id="dropdown-tests"
role="button"
href="{{ url_for('admin._tests') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Exams
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-tests"
>
<li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-settings">
<a
class="nav-link dropdown-toggle"
id="dropdown-settings"
role="button"
href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Settings
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
</li>
<li>
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
</li>
<li>
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-account">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._update_user', id=current_user.id) }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Account
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
</li>
<li>
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,18 @@
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:locale" content="en_UK" />
<meta property="og:type" content="website" />
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta name="twitter:creator" content="@viveksantayana" />
<meta name="twitter:site" content="@viveksantayana" />
<meta name="theme-color" content="#343a40" />
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">

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

@ -0,0 +1,43 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% set cookie_flash_flag = namespace(value=False) %}
{% for category, message in messages %}
{% if category == "error" %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "success" %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "warning" %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "cookie_alert" %}
{% if not cookie_flash_flag.value %}
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
{{ message|safe }}
<div class="d-flex justify-content-center w-100">
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
</div>
</div>
{% set cookie_flash_flag.value = True %}
{% endif %}
{% else %}
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" title="Alert"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,54 @@
{% extends "analysis/components/input-forms.html" %}
{% block content %}
<h1>Analysis</h1>
<div class="container">
<div class="row">
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Exams</h5>
<div class="card-text">
<div class="form-select-input">
<select name="select-test" id="select-test">
{% for test in tests %}
<option value="{{ test.id }}">{{ test.get_code() }}</option>
{% endfor %}
</select>
</div>
<div class="my-3">
<a href="{{ url_for('analysis._test') }}" class="btn btn-primary button-analyse" data-class="test">
<i class="bi bi-search button-icon"></i>
Analyse
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Datasets</h5>
<div class="card-text">
<div class="form-select-input">
<select name="select-dataset" id="select-dataset">
{% for dataset in datasets %}
<option value="{{ dataset.id }}">{{ dataset.get_name() }}</option>
{% endfor %}
</select>
</div>
<div class="my-3">
<a href="{{ url_for('analysis._dataset') }}" class="btn btn-primary button-analyse" data-class="dataset">
<i class="bi bi-search button-icon"></i>
Analyse
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% include "analysis/components/client-alerts.html" %}
{% endblock %}

View File

@ -0,0 +1,85 @@
from ..models import Dataset, Test
from ..tools.data import analyse, check_dataset_exists, check_test_exists
from ..tools.logs import write
from ..tools.data import parse_questions
from flask import Blueprint, render_template, request, jsonify
from flask.helpers import abort, flash, redirect, url_for
from flask_login import login_required
analysis = Blueprint(
name='analysis',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@analysis.route('/', methods=['GET','POST'])
@login_required
@check_dataset_exists
@check_test_exists
def _analysis():
try:
_tests = Test.query.all()
_datasets = Dataset.query.all()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
tests = [ test for test in _tests if test.entries ]
datasets = [ dataset for dataset in _datasets if dataset.entries ]
if request.method == 'POST':
selection = request.get_json()
if selection['class'] == 'test':
try:
test = Test.query.filter_by(id=selection['id']).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not test: return jsonify({'error': 'Invalid entry ID.'}), 404
return url_for('analysis._test', id=selection['id']), 200
if selection['class'] == 'dataset':
try:
dataset = Dataset.query.filter_by(id=selection['id']).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not dataset: return jsonify({'error': 'Invalid entry ID.'}), 404
return url_for('analysis._dataset', id=selection['id']), 200
return jsonify({'error': 'Invalid entry ID.'}), 404
return render_template('/analysis/index.html', tests=tests, datasets=datasets)
@analysis.route('/test/<string:id>')
@analysis.route('/test/')
@login_required
@check_test_exists
def _test(id:str=None):
if id in [None, '']:
flash(message='Please select a valid exam.', category='error')
return redirect(url_for('analysis._analysis'))
try:
test = Test.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not test:
flash('Invalid exam.', 'error')
return redirect(url_for('analysis._analysis'))
return render_template('/analysis/analysis.html', analysis=analyse(test), subject=test.get_code(), type='exam', dataset=test.dataset, questions=parse_questions(test.dataset.get_data()))
@analysis.route('/dataset/<string:id>')
@analysis.route('/dataset/')
@login_required
@check_dataset_exists
def _dataset(id:str=None):
if id in [None, '']:
flash(message='Please select a valid dataset.', category='error')
return redirect(url_for('analysis._analysis'))
try:
dataset = Dataset.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not dataset:
flash('Invalid dataset.', 'error')
return redirect(url_for('analysis._analysis'))
return render_template('/analysis/analysis.html', analysis=analyse(dataset), subject=dataset.get_name(), type='dataset', dataset=dataset, questions=parse_questions(dataset.get_data()))

View File

@ -6,7 +6,6 @@ from ..tools.test import evaluate_answers, generate_questions
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from flask.helpers import abort, flash, url_for from flask.helpers import abort, flash, url_for
from flask_login import login_required from flask_login import login_required
from sqlalchemy.exc import SQLAlchemyError
from datetime import datetime, timedelta from datetime import datetime, timedelta
from json import loads from json import loads
@ -20,7 +19,7 @@ api = Blueprint(
def _fetch_questions(): def _fetch_questions():
id = request.get_json()['id'] id = request.get_json()['id']
try: entry = Entry.query.filter_by(id=id).first() try: entry = Entry.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400 if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
@ -57,7 +56,7 @@ def _submit_quiz():
id = request.get_json()['id'] id = request.get_json()['id']
answers = request.get_json()['answers'] answers = request.get_json()['answers']
try: entry = Entry.query.filter_by(id=id).first() try: entry = Entry.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400 if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
@ -80,10 +79,8 @@ def _submit_quiz():
def _editor(id:str=None): def _editor(id:str=None):
request_data = request.get_json() request_data = request.get_json()
id = request_data['id'] id = request_data['id']
try: try: dataset = Dataset.query.filter_by(id=id).first()
dataset = Dataset.query.filter_by(id=id).first() except Exception as exception:
user = User.query.filter_by(id=creator).first()
except SQLAlchemyError as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404 if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
@ -94,6 +91,10 @@ def _editor(id:str=None):
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200 return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
default = request_data['default'] default = request_data['default']
creator = request_data['creator'] creator = request_data['creator']
try: user = User.query.filter_by(id=creator).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
name = request_data['name'] name = request_data['name']
data = request_data['data'] data = request_data['data']
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400 if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400

View File

@ -12,6 +12,7 @@ class Config(object):
SECRET_KEY = os.getenv('SECRET_KEY') SECRET_KEY = os.getenv('SECRET_KEY')
SERVER_NAME = os.getenv('SERVER_NAME') SERVER_NAME = os.getenv('SERVER_NAME')
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
WTF_CSRF_TIME_LIMIT = None
"""Email Engine Configuration""" """Email Engine Configuration"""
MAIL_SERVER = os.getenv('MAIL_SERVER') MAIL_SERVER = os.getenv('MAIL_SERVER')
@ -37,7 +38,7 @@ class Config(object):
MYSQL_USER = os.getenv('MYSQL_USER') MYSQL_USER = os.getenv('MYSQL_USER')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD') MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}' SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}'
else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/database.db"))}' else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/db.sqlite"))}'
class Production(Config): class Production(Config):
pass pass

View File

@ -1,2 +1,2 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p> <p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>

View File

@ -1,6 +1,6 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark"> <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container"> <div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a> <a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<button <button
class="navbar-toggler" class="navbar-toggler"
type="button" type="button"
@ -20,8 +20,28 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results"> <li class="nav-item dropdown" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a> <a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li> </li>
<li class="nav-item dropdown" id="nav-tests"> <li class="nav-item dropdown" id="nav-tests">
<a <a
@ -36,7 +56,7 @@
</a> </a>
<ul <ul
class="dropdown-menu" class="dropdown-menu"
aria-labelledby="dropdown-settings" aria-labelledby="dropdown-tests"
> >
<li> <li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a> <a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
@ -58,7 +78,7 @@
<li class="nav-item dropdown" id="nav-settings"> <li class="nav-item dropdown" id="nav-settings">
<a <a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
id="dropdown-account" id="dropdown-settings"
role="button" role="button"
href="{{ url_for('admin._settings') }}" href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"

View File

@ -7,7 +7,6 @@ from ..tools.logs import write
from flask import Blueprint, jsonify, render_template from flask import Blueprint, jsonify, render_template
from flask.helpers import abort, flash, redirect, request, url_for from flask.helpers import abort, flash, redirect, request, url_for
from flask_login import login_required from flask_login import login_required
from sqlalchemy.exc import SQLAlchemyError
editor = Blueprint( editor = Blueprint(
name='editor', name='editor',
@ -37,7 +36,7 @@ def _editor_console(id:str=None):
dataset = Dataset.query.filter_by(id=id).first() dataset = Dataset.query.filter_by(id=id).first()
datasets = Dataset.query.count() datasets = Dataset.query.count()
users = User.query.all() users = User.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not dataset: if not dataset:

View File

@ -5,20 +5,19 @@ from ..tools.logs import write
from flask import current_app as app from flask import current_app as app
from flask.helpers import flash from flask.helpers import flash
from flask_login import current_user from flask_login import current_user
from sqlalchemy.exc import SQLAlchemyError
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from datetime import datetime from datetime import datetime
from json import dump from json import dump, loads
from os import path, remove from os import path, remove
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
class Dataset(db.Model): class Dataset(db.Model):
id = db.Column(db.String(36), index=True, primary_key=True)
id = db.Column(db.String(36), primary_key=True)
name = db.Column(db.String(128), nullable=False) name = db.Column(db.String(128), nullable=False)
tests = db.relationship('Test', backref='dataset') tests = db.relationship('Test', backref='dataset')
entries = db.relationship('Entry', backref='dataset')
creator_id = db.Column(db.String(36), db.ForeignKey('user.id')) creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
date = db.Column(db.DateTime, nullable=False) date = db.Column(db.DateTime, nullable=False)
default = db.Column(db.Boolean, default=False, nullable=True) default = db.Column(db.Boolean, default=False, nullable=True)
@ -45,12 +44,12 @@ class Dataset(db.Model):
def make_default(self): def make_default(self):
try: try:
for dataset in Dataset.query.all(): dataset.default = False for dataset in Dataset.query.all(): dataset.default = False
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when setting default dataset {self.id}: {exception}') write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.' return False, f'Database error {exception}.'
self.default = True self.default = True
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when setting default dataset {self.id}: {exception}') write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.' return False, f'Database error {exception}.'
@ -68,7 +67,7 @@ class Dataset(db.Model):
message = 'Cannot delete the only dataset.' message = 'Cannot delete the only dataset.'
flash(message, 'error') flash(message, 'error')
return False, message return False, message
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when setting default dataset {self.id}: {exception}') write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.' return False, f'Database error {exception}.'
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.') write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
@ -78,7 +77,7 @@ class Dataset(db.Model):
try: try:
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when trying to delete dataset {self.id}: {exception}') write('system.log', f'Database error when trying to delete dataset {self.id}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -98,7 +97,7 @@ class Dataset(db.Model):
try: try:
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when trying to crreate dataset {self.id}: {exception}') write('system.log', f'Database error when trying to crreate dataset {self.id}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -117,6 +116,12 @@ class Dataset(db.Model):
file_path = path.join(data, 'questions', filename) file_path = path.join(data, 'questions', filename)
return file_path return file_path
def get_data(self):
dataset_path = self.get_file()
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
return data
def update(self, data:list=None, default:bool=False): def update(self, data:list=None, default:bool=False):
self.date = datetime.now() self.date = datetime.now()
if default: self.make_default() if default: self.make_default()
@ -128,7 +133,7 @@ class Dataset(db.Model):
try: try:
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when trying to update dataset {self.id}: {exception}') write('system.log', f'Database error when trying to update dataset {self.id}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'

View File

@ -1,32 +1,31 @@
from ..extensions import db, mail from ..extensions import db, mail
from ..tools.forms import JsonEncodedDict
from ..tools.encryption import decrypt, encrypt from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write from ..tools.logs import write
from .test import Test from .test import Test
from .dataset import Dataset
from flask_login import current_user from flask_login import current_user
from flask_mail import Message from flask_mail import Message
from smtplib import SMTPException from sqlalchemy_json import MutableJson
from sqlalchemy.exc import SQLAlchemyError
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import uuid4 from uuid import uuid4
class Entry(db.Model): class Entry(db.Model):
id = db.Column(db.String(36), index=True, primary_key=True)
id = db.Column(db.String(36), primary_key=True)
first_name = db.Column(db.String(128), nullable=False) first_name = db.Column(db.String(128), nullable=False)
surname = db.Column(db.String(128), nullable=False) surname = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(128), nullable=False) email = db.Column(db.String(128), nullable=False)
club = db.Column(db.String(128), nullable=True) club = db.Column(db.String(128), nullable=True)
test_id = db.Column(db.String(36), db.ForeignKey('test.id')) test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
user_code = db.Column(db.String(6), nullable=True) user_code = db.Column(db.String(6), nullable=True)
start_time = db.Column(db.DateTime, nullable=True) start_time = db.Column(db.DateTime, index=True, nullable=True)
end_time = db.Column(db.DateTime, nullable=True) end_time = db.Column(db.DateTime, index=True, nullable=True)
status = db.Column(db.String(16), nullable=True) status = db.Column(db.String(16), nullable=True)
valid = db.Column(db.Boolean, default=True, nullable=True) valid = db.Column(db.Boolean, default=True, nullable=True)
answers = db.Column(JsonEncodedDict, nullable=True) answers = db.Column(MutableJson, nullable=True)
result = db.Column(JsonEncodedDict, nullable=True) result = db.Column(MutableJson, nullable=True)
def __repr__(self): def __repr__(self):
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.' return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
@ -74,7 +73,7 @@ class Entry(db.Model):
try: try:
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when preparing new entry for {self.get_surname()}, {self.get_first_name()}: {exception}') write('system.log', f'Database error when preparing new entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -85,7 +84,7 @@ class Entry(db.Model):
self.start_time = datetime.now() self.start_time = datetime.now()
self.status = 'started' self.status = 'started'
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when starting test for {self.get_surname()}, {self.get_first_name()}: {exception}') write('system.log', f'Database error when starting test for {self.get_surname()}, {self.get_first_name()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -104,7 +103,7 @@ class Entry(db.Model):
self.status = 'late' self.status = 'late'
self.valid = False self.valid = False
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when submitting entry for {self.get_surname()}, {self.get_first_name()}: {exception}') write('system.log', f'Database error when submitting entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -117,7 +116,7 @@ class Entry(db.Model):
self.valid = True self.valid = True
self.status = 'completed' self.status = 'completed'
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when validating entry {self.id}: {exception}') write('system.log', f'Database error when validating entry {self.id}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -130,7 +129,7 @@ class Entry(db.Model):
try: try:
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when deleting entry {id}: {exception}') write('system.log', f'Database error when deleting entry {id}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -199,5 +198,4 @@ class Entry(db.Model):
""" """
) )
try: mail.send(email) try: mail.send(email)
except SMTPException as exception: except Exception as exception: write('system.log', f'SMTP Error when trying to notify results to {self.get_surname()}, {self.get_first_name()} with error: {exception}')
write('system.log', f'SMTP Error when trying to notify results to {self.get_surname()}, {self.get_first_name()} with error: {exception}')

View File

@ -1,24 +1,22 @@
from ..extensions import db from ..extensions import db
from ..tools.forms import JsonEncodedDict
from ..tools.logs import write from ..tools.logs import write
from flask_login import current_user from flask_login import current_user
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy_json import MutableJson
from datetime import date, datetime from datetime import date, datetime
import secrets import secrets
from uuid import uuid4 from uuid import uuid4
class Test(db.Model): class Test(db.Model):
id = db.Column(db.String(36), index=True, primary_key=True)
id = db.Column(db.String(36), primary_key=True) code = db.Column(db.String(36), index=True, nullable=False)
code = db.Column(db.String(36), nullable=False) start_date = db.Column(db.DateTime, index=True, nullable=True)
start_date = db.Column(db.DateTime, nullable=True)
end_date = db.Column(db.DateTime, nullable=True) end_date = db.Column(db.DateTime, nullable=True)
time_limit = db.Column(db.Integer, nullable=True) time_limit = db.Column(db.Integer, nullable=True)
creator_id = db.Column(db.String(36), db.ForeignKey('user.id')) creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id')) dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
adjustments = db.Column(JsonEncodedDict, nullable=True) adjustments = db.Column(MutableJson, nullable=True)
entries = db.relationship('Entry', backref='test') entries = db.relationship('Entry', backref='test')
def __repr__(self): def __repr__(self):
@ -56,7 +54,7 @@ class Test(db.Model):
try: try:
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when creating test {self.get_code()}: {exception}') write('system.log', f'Database error when creating test {self.get_code()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -67,7 +65,7 @@ class Test(db.Model):
if self.entries: return False, f'Cannot delete a test with submitted entries.' if self.entries: return False, f'Cannot delete a test with submitted entries.'
db.session.delete(self) db.session.delete(self)
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when deleting test {self.get_code()}: {exception}') write('system.log', f'Database error when deleting test {self.get_code()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -79,7 +77,7 @@ class Test(db.Model):
if self.start_date.date() > now.date(): if self.start_date.date() > now.date():
self.start_date = now self.start_date = now
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when launching test {self.get_code()}: {exception}') write('system.log', f'Database error when launching test {self.get_code()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -92,7 +90,7 @@ class Test(db.Model):
if self.end_date >= now: if self.end_date >= now:
self.end_date = now self.end_date = now
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when closing test {self.get_code()}: {exception}') write('system.log', f'Database error when closing test {self.get_code()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -106,7 +104,7 @@ class Test(db.Model):
adjustments[code] = time adjustments[code] = time
self.adjustments = adjustments self.adjustments = adjustments
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when adding adjustment to test {self.get_code()}: {exception}') write('system.log', f'Database error when adding adjustment to test {self.get_code()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -118,7 +116,7 @@ class Test(db.Model):
self.adjustments.pop(code) self.adjustments.pop(code)
if not self.adjustments: self.adjustments = None if not self.adjustments: self.adjustments = None
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when deleting adjustment from test {self.get_code()}: {exception}') write('system.log', f'Database error when deleting adjustment from test {self.get_code()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -131,7 +129,7 @@ class Test(db.Model):
if end_date: self.end_date = end_date if end_date: self.end_date = end_date
if time_limit is not None: self.time_limit = time_limit if time_limit is not None: self.time_limit = time_limit
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when updating test {self.get_code()}: {exception}') write('system.log', f'Database error when updating test {self.get_code()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'

View File

@ -6,18 +6,16 @@ from flask import jsonify, session
from flask.helpers import flash, url_for from flask.helpers import flash, url_for
from flask_login import current_user, login_user, logout_user, UserMixin from flask_login import current_user, login_user, logout_user, UserMixin
from flask_mail import Message from flask_mail import Message
from smtplib import SMTPException
from sqlalchemy.exc import SQLAlchemyError
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
import secrets import secrets
from uuid import uuid4 from uuid import uuid4
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.String(36), primary_key=True) id = db.Column(db.String(36), index=True, primary_key=True)
username = db.Column(db.String(128), nullable=False) username = db.Column(db.String(128), nullable=False)
password = db.Column(db.String(128), nullable=False) password = db.Column(db.String(128), nullable=False)
email = db.Column(db.String(128), nullable=False) email = db.Column(db.String(128), nullable=False)
reset_token = db.Column(db.String(20), nullable=True) reset_token = db.Column(db.String(20), index=True, nullable=True)
verification_token = db.Column(db.String(20), nullable=True) verification_token = db.Column(db.String(20), nullable=True)
tests = db.relationship('Test', backref='creator') tests = db.relationship('Test', backref='creator')
datasets = db.relationship('Dataset', backref='creator') datasets = db.relationship('Dataset', backref='creator')
@ -58,7 +56,7 @@ class User(UserMixin, db.Model):
def register(self, notify:bool=False, password:str=None): def register(self, notify:bool=False, password:str=None):
self.generate_id() self.generate_id()
try: users = User.query.all() try: users = User.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when setting default dataset {self.id}: {exception}') write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.' return False, f'Database error {exception}.'
for user in users: for user in users:
@ -68,7 +66,7 @@ class User(UserMixin, db.Model):
try: try:
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when registering user {self.get_username()}: {exception}') write('system.log', f'Database error when registering user {self.get_username()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -101,8 +99,7 @@ class User(UserMixin, db.Model):
""" """
) )
try: mail.send(email) try: mail.send(email)
except SMTPException as exception: except Exception as exception: write('system.log', f'SMTP Error while trying to notify new user account creation to {self.get_username()} with error: {exception}')
write('system.log', f'SMTP Error while trying to notify new user account creation to {self.get_username()} with error: {exception}')
return True, f'User {self.get_username()} was created successfully.' return True, f'User {self.get_username()} was created successfully.'
def login(self, remember:bool=False): def login(self, remember:bool=False):
@ -154,12 +151,12 @@ class User(UserMixin, db.Model):
""" """
) )
try: mail.send(email) try: mail.send(email)
except SMTPException as exception: except Exception as exception:
write('system.log', f'SMTP Error while trying to reset password for {self.get_username()} with error: {exception}') write('system.log', f'SMTP Error while trying to reset password for {self.get_username()} with error: {exception}')
db.session.rollback() db.session.rollback()
return jsonify({'error': f'SMTP Error: {exception}'}), 500 return jsonify({'error': f'SMTP Error: {exception}'}), 500
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when resetting password for user {self.get_username()}: {exception}') write('system.log', f'Database error when resetting password for user {self.get_username()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -168,7 +165,7 @@ class User(UserMixin, db.Model):
def clear_reset_tokens(self): def clear_reset_tokens(self):
self.reset_token = self.verification_token = None self.reset_token = self.verification_token = None
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when resetting clearing reset tokens for user {self.get_username()}: {exception}') write('system.log', f'Database error when resetting clearing reset tokens for user {self.get_username()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -179,7 +176,7 @@ class User(UserMixin, db.Model):
try: try:
db.session.delete(self) db.session.delete(self)
db.session.commit() db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when deleting user {self.get_username()}: {exception}') write('system.log', f'Database error when deleting user {self.get_username()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -208,8 +205,7 @@ class User(UserMixin, db.Model):
""" """
) )
try: mail.send(email) try: mail.send(email)
except SMTPException as exception: except Exception as exception: write('system.log', f'SMTP Error when trying to delete account {username} with error: {exception}')
write('system.log', f'SMTP Error when trying to delete account {username} with error: {exception}')
return True, message return True, message
def update(self, password:str=None, email:str=None, notify:bool=False): def update(self, password:str=None, email:str=None, notify:bool=False):
@ -220,12 +216,12 @@ class User(UserMixin, db.Model):
try: try:
for entry in User.query.all(): for entry in User.query.all():
if entry.get_email() == email and not entry == self: return False, f'The email address {email} is already in use.' if entry.get_email() == email and not entry == self: return False, f'The email address {email} is already in use.'
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when setting default dataset {self.id}: {exception}') write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
return False, f'Database error {exception}.' return False, f'Database error {exception}.'
self.set_email(email) self.set_email(email)
try: db.session.commit() try: db.session.commit()
except SQLAlchemyError as exception: except Exception as exception:
db.session.rollback() db.session.rollback()
write('system.log', f'Database error when updating user {self.get_username()}: {exception}') write('system.log', f'Database error when updating user {self.get_username()}: {exception}')
return False, f'Database error: {exception}' return False, f'Database error: {exception}'
@ -259,6 +255,5 @@ class User(UserMixin, db.Model):
""" """
) )
try: mail.send(message) try: mail.send(message)
except SMTPException as exception: except Exception as exception: write('system.log', f'SMTP Error when trying to update account {self.get_username()} with error: {exception}')
write('system.log', f'SMTP Error when trying to update account {self.get_username()} with error: {exception}')
return True, f'Account {self.get_username()} has been updated.' return True, f'Account {self.get_username()} has been updated.'

View File

@ -14,6 +14,9 @@
<div class="container quiz-start-text"> <div class="container quiz-start-text">
You can use this panel to adjust the display settings for the exam. Please use the menu below to select the font face and font size. Below is a sample question so you can see how the exam will render with your chosen settings. You can use this panel to adjust the display settings for the exam. Please use the menu below to select the font face and font size. Below is a sample question so you can see how the exam will render with your chosen settings.
</div> </div>
<div class="container quiz-start-text">
These settings will be stored locally on your browser window. No information about your preferences below will be collected by the app.
</div>
<div class="alert alert-primary quiz-start-text" role="alert"> <div class="alert alert-primary quiz-start-text" role="alert">
<strong>Note</strong>: Some fonts may not be available depending on your device and/or operating system. <strong>Note</strong>: Some fonts may not be available depending on your device and/or operating system.
</div> </div>

View File

@ -17,7 +17,7 @@
/> />
{% block style %} {% block style %}
{% endblock %} {% endblock %}
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title> <title>{% block title %} SKA Referee Test {% endblock %}</title>
{% include "quiz/components/og-meta.html" %} {% include "quiz/components/og-meta.html" %}
</head> </head>
<body class="bg-light"> <body class="bg-light">
@ -56,6 +56,8 @@
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13" integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous" crossorigin="anonymous"
></script> ></script>
<!-- jQuery UI -->
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
<!-- Custom js --> <!-- Custom js -->
<script type="text/javascript"> <script type="text/javascript">
var csrf_token = "{{ csrf_token() }}"; var csrf_token = "{{ csrf_token() }}";

View File

@ -1,3 +1,3 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p> <p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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>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> <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,6 +1,6 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav"> <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
<div class="container"> <div class="container">
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p> <p class="navbar-brand mb-0 h1">SKA Refereeing Test</p>
<div class="quiz-console w-100" style="display: none;" id="q-topbar"> <div class="quiz-console w-100" style="display: none;" id="q-topbar">
<div class="d-flex justify-content align-middle"> <div class="d-flex justify-content align-middle">
<div class="container d-flex justify-content-center"> <div class="container d-flex justify-content-center">

View File

@ -1,6 +1,10 @@
{% extends "quiz/components/base.html" %} {% extends "quiz/components/base.html" %}
{% import "bootstrap/wtf.html" as wtf %} {% import "bootstrap/wtf.html" as wtf %}
{% block style %}
<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/base/jquery-ui.css">
{% endblock %}
{% block content %} {% block content %}
<div class="form-container"> <div class="form-container">
<form name="form-quiz-start" class="form-quiz-start"> <form name="form-quiz-start" class="form-quiz-start">
@ -43,4 +47,14 @@
</div> </div>
</form> </form>
</div> </div>
{% endblock %}
{% block script %}
<script>
$( function() {
const clubs = {{ clubs|tojson }}
$('#club').autocomplete({
source: clubs
})
} )
</script>
{% endblock %} {% endblock %}

View File

@ -6,7 +6,6 @@ from ..tools.test import redirect_if_started
from flask import Blueprint, jsonify, render_template, request, session from flask import Blueprint, jsonify, render_template, request, session
from flask.helpers import abort, flash, redirect, url_for from flask.helpers import abort, flash, redirect, url_for
from sqlalchemy.exc import SQLAlchemyError
from datetime import datetime from datetime import datetime
@ -30,6 +29,23 @@ def _instructions():
@quiz.route('/start/', methods=['GET', 'POST']) @quiz.route('/start/', methods=['GET', 'POST'])
def _start(): def _start():
clubs = [
'Dundee Korfball Club',
'Edinburgh City Korfball Club',
'Edinburgh Mavericks Korfball Club',
'Edinburgh University Korfball Club',
'Glasgow Korfball Club',
'Saint Andrews University Korfball Club',
'Strathclyde University Korfball Club'
]
try: entries = Entry.query.all()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
for entry in entries: clubs.append(entry.get_club())
clubs = list(set(clubs))
try: clubs.remove('')
except: pass
form = StartQuiz() form = StartQuiz()
if request.method == 'POST': if request.method == 'POST':
if form.validate_on_submit(): if form.validate_on_submit():
@ -40,13 +56,14 @@ def _start():
entry.set_email(request.form.get('email')) entry.set_email(request.form.get('email'))
code = request.form.get('test_code').replace('', '').lower() code = request.form.get('test_code').replace('', '').lower()
try: test = Test.query.filter_by(code=code).first() try: test = Test.query.filter_by(code=code).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
entry.test = test entry.test = test
entry.dataset = test.dataset
entry.user_code = request.form.get('user_code') entry.user_code = request.form.get('user_code')
entry.user_code = None if entry.user_code == '' else entry.user_code.lower() entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
if entry.user_code and entry.user_code not in test.adjustments: return jsonify({'error': f'The user code you entered is not valid.'}), 400 if entry.user_code and entry.user_code not in test.adjustments: return jsonify({'error': f'The user code you entered is not valid.'}), 400
if test.end_date < datetime.now(): return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y %H:%M")}.'}), 400 if test.end_date < datetime.now(): return jsonify({'error': f'The exam code you entered expired on {test["expiry_date"].strftime("%d %b %Y %H:%M")}.'}), 400
if test.start_date > datetime.now(): return jsonify({'error': f'The exam has not yet opened. Your exam code will be valid from {test["start_date"].strftime("%d %b %Y %H:%M")}.'}), 400 if test.start_date > datetime.now(): return jsonify({'error': f'The exam has not yet opened. Your exam code will be valid from {test["start_date"].strftime("%d %b %Y %H:%M")}.'}), 400
@ -59,7 +76,7 @@ def _start():
}), 200 }), 200
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400 return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
return send_errors_to_client(form=form) return send_errors_to_client(form=form)
return render_template('/quiz/start_quiz.html', form = form) return render_template('/quiz/start_quiz.html', form = form, clubs = clubs)
@quiz.route('/quiz/') @quiz.route('/quiz/')
def _quiz(): def _quiz():
@ -69,7 +86,7 @@ def _quiz():
flash('Your session was not recognised. Please sign in to the quiz again.', 'error') flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
session.pop('id', None) session.pop('id', None)
return redirect(url_for('quiz._start')) return redirect(url_for('quiz._start'))
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
return render_template('/quiz/client.html') return render_template('/quiz/client.html')
@ -78,7 +95,7 @@ def _quiz():
def _result(): def _result():
id = session.get('id') id = session.get('id')
try: entry = Entry.query.filter_by(id=id).first() try: entry = Entry.query.filter_by(id=id).first()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not entry: return abort(404) if not entry: return abort(404)

View File

@ -18,7 +18,7 @@
<link rel="shortcut icon" href="{{ url_for('views.static', filename='favicon.ico') }}"> <link rel="shortcut icon" href="{{ url_for('views.static', filename='favicon.ico') }}">
{% block style %} {% block style %}
{% endblock %} {% endblock %}
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title> <title>{% block title %} SKA Referee Test {% endblock %}</title>
</head> </head>
<body class="bg-light"> <body class="bg-light">

View File

@ -17,7 +17,7 @@
/> />
{% block style %} {% block style %}
{% endblock %} {% endblock %}
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title> <title>{% block title %} SKA Referee Test {% endblock %}</title>
{% include "components/og-meta.html" %} {% include "components/og-meta.html" %}
</head> </head>
<body class="bg-light"> <body class="bg-light">

View File

@ -1,3 +1,3 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p> <p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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>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> <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,6 +1,6 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav"> <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
<div class="container"> <div class="container">
<p class="navbar-brand mb-0 h1">SKA Refereeing Test (Beta)</p> <p class="navbar-brand mb-0 h1">SKA Refereeing Test</p>
<div class="quiz-console w-100" style="display: none;" id="q-topbar"> <div class="quiz-console w-100" style="display: none;" id="q-topbar">
<div class="d-flex justify-content align-middle"> <div class="d-flex justify-content align-middle">
<div class="container d-flex justify-content-center"> <div class="container d-flex justify-content-center">

View File

@ -3,9 +3,19 @@
{% block content %} {% block content %}
<h1>Privacy Policy</h1> <h1>Privacy Policy</h1>
This web app stores data using cookies. The web site only stores the minimum information it needs to function. <ul>
<h5>Site Administrators</h5> <li>
This web app stores data using cookies. The web site only stores the minimum information it needs to function.
</li>
<li>
All data stored on this app can be accessed by the SKA Committee and the maintainer of this app.
</li>
<li>
This app is currently maintained by Vivek Santayana, a member of the Edinburgh City Korfball Club, with the permission of the SKA Committee.
</li>
</ul>
<h5>Site Administrators</h5>
<ul> <ul>
<li>For site administrators, this web site uses encrypted cookies to store data from your log-in session.</li> <li>For site administrators, this web site uses encrypted cookies to store data from your log-in session.</li>
<li>User information for administrators is encrypted and stored in a secure database, and are expunged when an account is deleted.</li> <li>User information for administrators is encrypted and stored in a secure database, and are expunged when an account is deleted.</li>
@ -13,14 +23,14 @@
<h5>Test Candidates</h5> <h5>Test Candidates</h5>
<ul> <ul>
<li>The web site will not be trackin your log in, and all information about your test attempt will be stored on your device until you submit it to the server.</li> <li>The web site will not be tracking your log in, and all information about your test attempt will be stored on your device until you submit it to the server.</li>
<li>Data from your test, including identifying information such as your name and email address, will be recorded by the Scottish Korfball Association in order to oversee the training and qualification of referees.</li> <li>Data from your test, including identifying information such as your name and email address, will be recorded by the Scottish Korfball Association in order to oversee the training and qualification of referees.</li>
<li>These records will be kept for three years or until the expiration of the theory exam qualification (whichever is later), and will be expunged securely thereafter.</li> <li>These records will be kept for three years or until the expiration of the theory exam qualification (whichever is later), and will be expunged securely thereafter.</li>
<li>All identifying information about candidates will be encrypted and stored in a secure database.</li> <li>All identifying information about candidates will be encrypted and stored in a secure database administered by the maintainer of this app.</li>
</ul> </ul>
<h5>Requests to Delete Data</h5> <h5>Requests to Delete Data</h5>
<ul> <ul>
<li>You can request to have any of your data that is held here deleted by emailing <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a>.</li> <li>You can request to view or delete data that the app stores about you by emailing <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a>.</li>
</ul> </ul>
{% endblock %} {% endblock %}

View File

@ -1,10 +1,8 @@
from .data import load
from ..models import User from ..models import User
from ..tools.logs import write from ..tools.logs import write
from flask.helpers import abort, flash, redirect, url_for from flask.helpers import abort, flash, redirect, url_for
from flask_login import current_user from flask_login import current_user
from sqlalchemy.exc import SQLAlchemyError
from functools import wraps from functools import wraps
@ -15,7 +13,7 @@ def require_account_creation(function):
if User.query.count() == 0: if User.query.count() == 0:
flash('Please register a user account.', 'alert') flash('Please register a user account.', 'alert')
return redirect(url_for('admin._register')) return redirect(url_for('admin._register'))
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when checking for existing accounts: {exception}') write('system.log', f'Database error when checking for existing accounts: {exception}')
return abort(500) return abort(500)
return function(*args, **kwargs) return function(*args, **kwargs)

View File

@ -1,13 +1,14 @@
from ..models import Dataset from ..models import Dataset, Test
from ..tools.logs import write from ..tools.logs import write
from flask import current_app as app from flask import current_app as app
from flask.helpers import abort, flash, redirect, url_for from flask.helpers import abort, flash, redirect, url_for
from sqlalchemy.exc import SQLAlchemyError
import json import json
from pathlib import Path from pathlib import Path
from random import shuffle from random import shuffle
from statistics import mean, median, stdev
from typing import Union
from functools import wraps from functools import wraps
def load(filename:str): def load(filename:str):
@ -78,11 +79,90 @@ def check_dataset_exists(function):
@wraps(function) @wraps(function)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: datasets = Dataset.query.all() try: datasets = Dataset.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when checking existing datasets: {exception}') write('system.log', f'Database error when checking existing datasets: {exception}')
return abort(500) return abort(500)
if not datasets: if not datasets:
flash('There are no available question datasets. Please upload a question dataset first, or use the question editor to create a new dataset.', 'error') flash('There are no available question datasets. Please upload a question dataset first, or use the question editor to create a new dataset.', 'error')
return redirect(url_for('admin._questions')) return redirect(url_for('admin._questions'))
return function(*args, **kwargs) return function(*args, **kwargs)
return wrapper return wrapper
def check_test_exists(function):
@wraps(function)
def wrapper(*args, **kwargs):
try: tests = Test.query.all()
except Exception as exception:
write('system.log', f'Database error when checking existing datasets: {exception}')
return abort(500)
if not tests:
flash('There are no exams configured. Please create an exam first.', 'error')
return redirect(url_for('admin._tests'))
return function(*args, **kwargs)
return wrapper
def analyse(subject:Union[Dataset,Test]) -> dict:
output = {
'answers': {},
'entries': 0,
'grades': {
'merit': 0,
'pass': 0,
'fail': 0
},
'scores': {
'mean': 0,
'median': 0,
'stdev': 0
}
}
scores_raw = []
if isinstance(subject, Test):
for entry in subject.entries:
if entry.answers:
for question, answer in entry.answers.items():
if int(question) not in output['answers']: output['answers'][int(question)] = {}
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
output['answers'][int(question)][int(answer)] += 1
if entry.result:
output['entries'] += 1
output['grades'][entry.result['grade']] += 1
scores_raw.append(int(entry.result['score']))
else:
for test in subject.tests:
for entry in test.entries:
if entry.answers:
for question, answer in entry.answers.items():
if int(question) not in output['answers']: output['answers'][int(question)] = {}
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
output['answers'][int(question)][int(answer)] += 1
if entry.result:
output['entries'] += 1
output['grades'][entry.result['grade']] += 1
scores_raw.append(entry.result['score'])
output['scores']['mean'] = mean(scores_raw)
output['scores']['median'] = median(scores_raw)
output['scores']['stdev'] = stdev(scores_raw, output['scores']['mean']) if len(scores_raw) > 1 else None
return output
def parse_questions(dataset:list):
output = []
for block in dataset:
if block['type'] == 'question':
question = {
'q_no': block['q_no'],
'tags': block['tags'],
'correct': block['correct']
}
question['options'] = [*enumerate(block['options'])]
output.append(question)
elif block['type'] == 'block':
for _question in block['questions']:
question = {
'q_no': _question['q_no'],
'tags': _question['tags'],
'correct': _question['correct']
}
question['options'] = [*enumerate(_question['options'])]
output.append(question)
return output

View File

@ -1,32 +1,8 @@
from ..extensions import db
from ..tools.logs import write from ..tools.logs import write
from flask import jsonify from flask import jsonify
from sqlalchemy.exc import SQLAlchemyError
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
import json
from sqlalchemy.ext import mutable
class JsonEncodedDict(db.TypeDecorator):
"""Enables JSON storage by encoding and decoding on the fly."""
impl = db.Text
def process_bind_param(self, value, dialect):
if value is None:
return '{}'
else:
return json.dumps(value)
def process_result_value(self, value, dialect):
if value is None:
return {}
else:
return json.loads(value)
mutable.MutableDict.associate_with(JsonEncodedDict)
def value(min:int=0, max:int=None): def value(min:int=0, max:int=None):
if not max: if not max:
message = f'Value must be greater than {min}.' message = f'Value must be greater than {min}.'
@ -50,7 +26,7 @@ def get_time_options():
def get_dataset_choices(): def get_dataset_choices():
from ..models import Dataset from ..models import Dataset
try: datasets = Dataset.query.all() try: datasets = Dataset.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when fetching dataset lists: {exception}') write('system.log', f'Database error when fetching dataset lists: {exception}')
return [] return []
dataset_choices = [] dataset_choices = []

View File

@ -4,16 +4,16 @@ from ..tools.logs import write
from flask import request, session from flask import request, session
from flask.helpers import abort, redirect, url_for from flask.helpers import abort, redirect, url_for
from sqlalchemy.exc import SQLAlchemyError
from functools import wraps from functools import wraps
def parse_test_code(code): def parse_test_code(code):
return code.replace('', '').lower() return code.replace('', '').lower()
def generate_questions(dataset:list): def generate_questions(dataset:list, randomise:bool=True):
output = [] output = []
for block in randomise_list(dataset): question_dataset = randomise_list(dataset) if randomise else dataset
for block in question_dataset:
if block['type'] == 'question': if block['type'] == 'question':
question = { question = {
'type': 'question', 'type': 'question',
@ -21,11 +21,12 @@ def generate_questions(dataset:list):
'question_header': '', 'question_header': '',
'text': block['text'] 'text': block['text']
} }
if block['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(block['options'])]) if block['q_type'] == 'Multiple Choice' and randomise: question['options'] = randomise_list([*enumerate(block['options'])])
else: question['options'] = [*enumerate(block['options'])] else: question['options'] = [*enumerate(block['options'])]
output.append(question) output.append(question)
elif block['type'] == 'block': elif block['type'] == 'block':
for key, _question in enumerate(randomise_list(block['questions'])): block_questions = randomise_list(block['questions']) if randomise else block['questions']
for key, _question in enumerate(block_questions):
question = { question = {
'type': 'block', 'type': 'block',
'q_no': _question['q_no'], 'q_no': _question['q_no'],
@ -34,7 +35,7 @@ def generate_questions(dataset:list):
'block_q_no': key, 'block_q_no': key,
'text': _question['text'] 'text': _question['text']
} }
if _question['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(_question['options'])]) if _question['q_type'] == 'Multiple Choice' and randomise: question['options'] = randomise_list([*enumerate(_question['options'])])
else: question['options'] = [*enumerate(_question['options'])] else: question['options'] = [*enumerate(_question['options'])]
output.append(question) output.append(question)
return output return output
@ -133,7 +134,7 @@ def redirect_if_started(function):
id = session.get('id') id = session.get('id')
try: try:
if request.method == 'GET' and id and Entry.query.filter_by(id=id).first(): return redirect(url_for('quiz._quiz')) if request.method == 'GET' and id and Entry.query.filter_by(id=id).first(): return redirect(url_for('quiz._quiz'))
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when checking if test has been started: {exception}') write('system.log', f'Database error when checking if test has been started: {exception}')
return abort(500) return abort(500)
return function(*args, **kwargs) return function(*args, **kwargs)

View File

@ -110,6 +110,27 @@ function parse_data(data) {
} }
} }
// Analyse Button
$('.dataset-analyse').click(function(event) {
let id = $(this).data('id')
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'dataset'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
event.preventDefault()
})
// Fetch data once page finishes loading // Fetch data once page finishes loading
$(window).on('load', function() { $(window).on('load', function() {
$.ajax({ $.ajax({

View File

@ -1,2 +1,2 @@
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p> <p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&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>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>

View File

@ -1,6 +1,6 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark"> <nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container"> <div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a> <a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<button <button
class="navbar-toggler" class="navbar-toggler"
type="button" type="button"
@ -20,8 +20,28 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results"> <li class="nav-item dropdown" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a> <a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li> </li>
<li class="nav-item dropdown" id="nav-tests"> <li class="nav-item dropdown" id="nav-tests">
<a <a
@ -36,7 +56,7 @@
</a> </a>
<ul <ul
class="dropdown-menu" class="dropdown-menu"
aria-labelledby="dropdown-settings" aria-labelledby="dropdown-tests"
> >
<li> <li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a> <a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
@ -58,7 +78,7 @@
<li class="nav-item dropdown" id="nav-settings"> <li class="nav-item dropdown" id="nav-settings">
<a <a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
id="dropdown-account" id="dropdown-settings"
role="button" role="button"
href="{{ url_for('admin._settings') }}" href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"

View File

@ -100,7 +100,18 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="d-flex justify-content-center">
<a
href="#"
class="btn btn-success dataset-analyse {% if not dataset.entries %} disabled {% endif %}"
data-id="{{dataset.id}}"
title="Analyse Answers"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
Analyse Answers
</a>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,6 @@ from ..tools.logs import write
from flask import Blueprint, jsonify, render_template, request from flask import Blueprint, jsonify, render_template, request
from flask.helpers import abort, flash, redirect, url_for from flask.helpers import abort, flash, redirect, url_for
from flask_login import login_required from flask_login import login_required
from sqlalchemy.exc import SQLAlchemyError
view = Blueprint( view = Blueprint(
name='view', name='view',
@ -38,10 +37,10 @@ def _view_console(id:str=None):
dataset = Dataset.query.filter_by(id=id).first() dataset = Dataset.query.filter_by(id=id).first()
datasets = Dataset.query.count() datasets = Dataset.query.count()
users = User.query.all() users = User.query.all()
except SQLAlchemyError as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
if not dataset: if not dataset:
flash('Invalid dataset ID.', 'error') flash('Invalid dataset ID.', 'error')
return redirect(url_for('admin._questions')) return redirect(url_for('admin._questions'))
return render_template('/view/console.html', dataset=dataset, datasets=datasets, users=users) return render_template('/view/console.html', dataset=dataset)

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
from main import app from main import app
from app.extensions import db from app.extensions import db
from app.models import *
from app.tools.data import save from app.tools.data import save
from app.tools.logs import write from app.tools.logs import write
from sqlalchemy_utils import create_database, database_exists from sqlalchemy_utils import create_database, database_exists
@ -21,14 +22,14 @@ with app.app_context():
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.') 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/system.log'): write('system.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.') if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
if not database_exists(database_uri):
create_database(database_uri)
write('system.log', 'No database found. Creating a new database.')
from app.models import *
db.create_all()
write('system.log', 'Creating database schema.')
if not path.isfile(f'./{data}/.encryption.key'): if not path.isfile(f'./{data}/.encryption.key'):
write('system.log', 'No encryption key found. Generating new encryption key.') write('system.log', 'No encryption key found. Generating new encryption key.')
with open(f'./{data}/.encryption.key', 'wb') as key_file: with open(f'./{data}/.encryption.key', 'wb') as key_file:
key = Fernet.generate_key() key = Fernet.generate_key()
key_file.write(key) key_file.write(key)
"""Create File for SQLite Database"""
if database_uri[0:6].lower() == 'sqlite':
if not database_exists(database_uri):
create_database(database_uri)
write('system.log', 'No SQLite file found. Creating a new database.')

View File

@ -1,28 +1,33 @@
blinker==1.5 blinker==1.5
cffi==1.15.1 cffi==1.15.1
click==8.1.3 click==8.1.3
cryptography==37.0.4 cryptography==39.0.2
dnspython==2.2.1 dnspython==2.3.0
dominate==2.7.0 dominate==2.7.0
email-validator==1.2.1 email-validator==1.3.1
Flask==2.2.2 Flask==2.2.3
Flask-Bootstrap==3.3.7.1 Flask-Bootstrap==3.3.7.1
Flask-Login==0.6.2 Flask-Login==0.6.2
Flask-Mail==0.9.1 Flask-Mail==0.9.1
Flask-SQLAlchemy==2.5.1 Flask-SQLAlchemy==3.0.3
Flask-WTF==1.0.1 Flask-WTF==1.1.1
greenlet==1.1.2 greenlet==2.0.2
gunicorn==20.1.0 gunicorn==20.1.0
idna==3.3 idna==3.4
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.1 MarkupSafe==2.1.2
pip==23.0.1
pycparser==2.21 pycparser==2.21
PyMySQL==1.0.2 PyMySQL==1.0.2
python-dotenv==0.20.0 python-dotenv==1.0.0
setuptools==67.4.0
six==1.16.0 six==1.16.0
SQLAlchemy==1.4.40 SQLAlchemy==2.0.4
SQLAlchemy-Utils==0.38.3 sqlalchemy-json==0.5.0
SQLAlchemy-Utils==0.40.0
typing_extensions==4.5.0
visitor==0.1.3 visitor==0.1.3
Werkzeug==2.2.2 Werkzeug==2.2.3
wheel==0.38.4
WTForms==3.0.1 WTForms==3.0.1

View File

@ -2,15 +2,12 @@
from main import app from main import app
from app.models import User from app.models import User
from sqlalchemy.exc import SQLAlchemyError
import sys import sys
from getpass import getpass from getpass import getpass
with app.app_context(): with app.app_context():
try: users = User.query.all() try: users = User.query.all()
except SQLAlchemyError as exception: except Exception as exception: sys.exit('Database error:', exception)
sys.exit('Database error:', exception)
print('') print('')
print('This interface will allow you to override the password for an administrator account.') print('This interface will allow you to override the password for an administrator account.')
print('To exit this interface, press Ctrl + C.') print('To exit this interface, press Ctrl + C.')