Compare commits
68 Commits
b8fd65d856
...
v1.2.1
Author | SHA1 | Date | |
---|---|---|---|
79ad96a93f | |||
ba851cb7dc | |||
fcc4d55947 | |||
a56358b8dd | |||
179a608089 | |||
a1289da09c | |||
ea86fd9ae6 | |||
76d60546e2 | |||
9a02048199 | |||
c9ad8e87cd | |||
3714919ba5 | |||
1026cc71a9 | |||
07fb170656 | |||
1ea93994ab | |||
607b132996 | |||
7aa4d81e65 | |||
0ef39dcfbe | |||
e1517b89c0 | |||
d0ed228824 | |||
a2c52a4261 | |||
b2c9bdd7d2 | |||
7536c33a48 | |||
850c2b13b7 | |||
eb69979f59 | |||
95cea46a8f | |||
02a1129390 | |||
438e09f1ec | |||
9241e1c0f7 | |||
8deefb9035 | |||
4f2984deea | |||
70d2325579 | |||
36d840c752 | |||
4400446718 | |||
adead30a77 | |||
487f24732d | |||
3c06cebddf | |||
d1d52fa4b6 | |||
80dc8b3cff | |||
a9ccd64de2 | |||
f5b9758bb1 | |||
84570d5974 | |||
edb8241ad3 | |||
644a539ed9 | |||
f05568b0de | |||
da4a3e41c6 | |||
77f86f7102 | |||
358695977f | |||
ddfd75c1f8 | |||
f4642767ac | |||
2f729de40b | |||
d68beb938f | |||
ca667f7896 | |||
0cc00ef911 | |||
5ec2a86d08 | |||
cd57eca7d3 | |||
a46338fdcb | |||
40f1cebb7b | |||
2a6478f3cf | |||
b6e250a7cd | |||
bcee2eedd0 | |||
d9837246de | |||
62fac48904 | |||
2bf0eeb33d | |||
72f2af1df8 | |||
168b2b288a | |||
9a5f69f889 | |||
7d6f256392 | |||
866c9b10cf |
12
.env.example
12
.env.example
@ -3,9 +3,19 @@ FLASK_DEBUG=False
|
||||
|
||||
TZ=Europe/London # Time Zone
|
||||
|
||||
## Flask Configuration
|
||||
## App Configuration
|
||||
SECRET_KEY= # Long, secure, secret string.
|
||||
DATA=./data/
|
||||
DATABASE_TYPE=SQLite # SQLite or MySQL, defaults to SQLite
|
||||
DATABASE_HOST= # Required if MySQL. Must match name of Docker service, or provide host if database is on an external server. Defaults to localhost.
|
||||
DATABASE_PORT= # Required if MySQL. Defaults to 3306
|
||||
|
||||
## MySQL Database Configuration (Required if configured to MySQL Database.)
|
||||
# Note that if using the Docker service, these configuration values will also be used when creating the database in the mysql container.
|
||||
MYSQL_RANDOM_ROOT_PASSWORD=True
|
||||
MYSQL_DATABASE= # Required if MySQL.
|
||||
MYSQL_USER= # Required if MySQL
|
||||
MYSQL_PASSWORD= # Required if MySQL. Create secure password string. Note '@' character cannot be used.
|
||||
|
||||
## Flask Mail Configuration
|
||||
MAIL_SERVER=postfix # Must match name of the Docker service
|
||||
|
10
.gitignore
vendored
10
.gitignore
vendored
@ -153,3 +153,13 @@ database/data/
|
||||
|
||||
# Ignore Data Dir
|
||||
**/data/*
|
||||
|
||||
# Ignore Logs Dir
|
||||
logs/*
|
||||
|
||||
# Ignore Certbot Dir
|
||||
certbot/*
|
||||
|
||||
# Ignore src dir (exception for robots.txt)
|
||||
src/html/*
|
||||
src/html/robots.txt
|
10
README.md
10
README.md
@ -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.)
|
||||
|
||||
#### 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
|
||||
|
||||
Configuration values for the app are stored in the environment variables file.
|
||||
|
2
certbot/.gitignore
vendored
2
certbot/.gitignore
vendored
@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
90
docker-compose-mysql.yml
Normal file
90
docker-compose-mysql.yml
Normal file
@ -0,0 +1,90 @@
|
||||
version: '3.9'
|
||||
|
||||
volumes:
|
||||
app:
|
||||
mysql:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt:ro
|
||||
- ./nginx:/etc/nginx
|
||||
- ./src/html/certbot:/usr/share/nginx/html/certbot:ro
|
||||
- ./src/html/robots.txt:/usr/share/nginx/html/robots.txt:ro
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root:ro
|
||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static:ro
|
||||
- ./ref-test/app/editor/static:/usr/share/nginx/html/editor/static:ro
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static:ro
|
||||
- ./ref-test/app/view/static:/usr/share/nginx/html/view/static:ro
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
depends_on:
|
||||
- app
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
app:
|
||||
container_name: reftest_app
|
||||
image: reftest
|
||||
build: ./ref-test
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- app:/ref-test/data
|
||||
- ./logs:/ref-test/data/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
- backend
|
||||
depends_on:
|
||||
postfix:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
|
||||
postfix:
|
||||
container_name: reftest_postfix
|
||||
image: catatnight/postfix:latest
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./.env
|
||||
ports:
|
||||
- 25
|
||||
networks:
|
||||
- backend
|
||||
|
||||
certbot:
|
||||
container_name: reftest_certbot
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./certbot:/etc/letsencrypt
|
||||
- ./src/html/certbot:/var/www/html
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
mysql:
|
||||
container_name: reftest_db
|
||||
image: mysql:8.0
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- mysql:/var/lib/mysql
|
||||
ports:
|
||||
- 3306
|
||||
networks:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
networks:
|
||||
frontend:
|
||||
external: false
|
||||
backend:
|
||||
external: false
|
@ -1,7 +1,7 @@
|
||||
version: '3.9'
|
||||
|
||||
volumes:
|
||||
data:
|
||||
app:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
@ -10,6 +10,7 @@ services:
|
||||
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
|
||||
@ -35,7 +36,8 @@ services:
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- data:/ref-test/data
|
||||
- app:/ref-test/data
|
||||
- ./logs:/ref-test/data/logs
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
@ -59,7 +61,7 @@ services:
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- ./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;'"
|
||||
|
||||
networks:
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Certbot Renewal
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /usr/share/nginx/html;
|
||||
root /usr/share/nginx/html/certbot;
|
||||
allow all;
|
||||
default_type "text/plain";
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
env/
|
||||
__pycache__/
|
||||
data/
|
@ -1,9 +1,10 @@
|
||||
from .config import Production as Config
|
||||
from .models import User
|
||||
from .models import *
|
||||
from .extensions import bootstrap, csrf, db, login_manager, mail
|
||||
from .tools.logs import write
|
||||
|
||||
from flask import flash, Flask, render_template, request
|
||||
from flask.helpers import url_for
|
||||
from flask.helpers import abort, url_for
|
||||
from flask.json import jsonify
|
||||
from flask_wtf.csrf import CSRFError
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
@ -24,7 +25,10 @@ def create_app():
|
||||
login_manager.login_view = 'admin._login'
|
||||
@login_manager.user_loader
|
||||
def _load_user(id):
|
||||
return User.query.filter_by(id=id).first()
|
||||
try: return User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when loading user fo login manager: {exception}')
|
||||
return abort(500)
|
||||
|
||||
@app.before_request
|
||||
def _check_cookie_consent():
|
||||
@ -47,6 +51,7 @@ def create_app():
|
||||
from .views import views
|
||||
from .editor.views import editor
|
||||
from .view.views import view
|
||||
from .analysis.views import analysis
|
||||
|
||||
app.register_blueprint(admin, url_prefix='/admin')
|
||||
app.register_blueprint(api, url_prefix='/api')
|
||||
@ -54,5 +59,12 @@ def create_app():
|
||||
app.register_blueprint(quiz)
|
||||
app.register_blueprint(editor, url_prefix='/admin/editor')
|
||||
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
|
@ -88,6 +88,19 @@ $('.test-action').click(function(event) {
|
||||
})
|
||||
} else if (action == 'edit') {
|
||||
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()
|
||||
@ -123,6 +136,19 @@ $('.edit-question-dataset').click(function(event) {
|
||||
window.location.href = `/admin/view/${id}`
|
||||
} else if (action == 'download') {
|
||||
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()
|
||||
|
@ -50,7 +50,7 @@
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Time</h5>
|
||||
</div>
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
@ -59,7 +59,7 @@
|
||||
<span class="badge bg-danger">Late</span>
|
||||
{% endif %}
|
||||
</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 class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
|
@ -1,2 +1,2 @@
|
||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -1,6 +1,6 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
@ -20,8 +20,28 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-results">
|
||||
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
||||
<li 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
|
||||
@ -36,7 +56,7 @@
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
aria-labelledby="dropdown-tests"
|
||||
>
|
||||
<li>
|
||||
<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">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
id="dropdown-settings"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
|
@ -28,7 +28,7 @@
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ test.end_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -72,10 +72,14 @@
|
||||
<a href="{{ url_for('admin._view_entry', id=result.id) }}">{{ result.get_surname() }}, {{ result.get_first_name() }}</a>
|
||||
</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>
|
||||
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||
{% if result.result %}
|
||||
{{ (100*result.result['score']/result.result['max'])|round|int }}% ({{ result.result.grade }})
|
||||
{% else %}
|
||||
Incomplete
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -117,7 +121,7 @@
|
||||
<a href="{{ url_for('admin._view_test', id=test.id) }}">{{ test.get_code() }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ test.end_date.strftime('%d %b %Y') }}
|
||||
{{ test.end_date.strftime('%d %b %Y') if test.end_date else None }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -138,7 +142,7 @@
|
||||
<div class="card m-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Help</h5>
|
||||
<p class="card-text">This web app was developed by Vivek Santayana. If there are any issues with the app, any bugs you need to report, or any features you would like to request, please feel free to <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test/issues">open an issue at the Git Repository</a>.</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -49,7 +49,7 @@
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Time</h5>
|
||||
</div>
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') }}
|
||||
{{ entry.start_time.strftime('%d %b %Y %H:%M:%S') if entry.start_time else None }}
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
@ -108,7 +108,7 @@
|
||||
{% for tag, scores in entry.result.tags.items() %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ tag }}
|
||||
{{ tag|safe }}
|
||||
</td>
|
||||
<td>
|
||||
{{ scores.scored }}
|
||||
|
@ -52,6 +52,15 @@
|
||||
{{ element.tests|length }}
|
||||
</td>
|
||||
<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
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
@ -63,7 +72,7 @@
|
||||
</a>
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
class="btn btn-primary view-question-dataset"
|
||||
class="btn btn-primary edit-question-dataset"
|
||||
data-id="{{ element.id }}"
|
||||
data-action="view"
|
||||
title="View Questions"
|
||||
|
@ -19,7 +19,7 @@
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Dataset</h5>
|
||||
</div>
|
||||
{{ test.dataset.date.strftime('%Y%m%d%H%M%S') }}
|
||||
<a href="{{ url_for('view._view_console', id=test.dataset.id) }}">{{ test.dataset.get_name() }}</a>
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
@ -32,13 +32,13 @@
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Start Date</h5>
|
||||
</div>
|
||||
{{ test.start_date.strftime('%d %b %Y %H:%M') }}
|
||||
{{ test.start_date.strftime('%d %b %Y %H:%M') if test.start_date else None }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Expiry Date</h5>
|
||||
</div>
|
||||
{{ test.end_date.strftime('%d %b %Y %H:%M') }}
|
||||
{{ test.end_date.strftime('%d %b %Y %H:%M') if test.end_date else None }}
|
||||
</li>
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
@ -71,7 +71,7 @@
|
||||
{% for entry in test.entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin._view_entry', id=entry) }}" >Entry {{ loop.index }}</a>
|
||||
<a href="{{ url_for('admin._view_entry', id=entry.id) }}" >Entry {{ loop.index }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@ -162,7 +162,7 @@
|
||||
{% include "admin/components/client-alerts.html" %}
|
||||
</div>
|
||||
<div class="container justify-content-center">
|
||||
<div class="row">
|
||||
<div class="my-3 row">
|
||||
{% if test.start_date <= now %}
|
||||
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
|
||||
<i class="bi bi-hourglass-bottom button-icon"></i>
|
||||
@ -174,6 +174,16 @@
|
||||
Start Exam
|
||||
</a>
|
||||
{% 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 }}">
|
||||
<i class="bi bi-file-earmark-excel-fill button-icon"></i>
|
||||
Delete Exam
|
||||
|
@ -58,6 +58,15 @@
|
||||
{{ test.entries|length }}
|
||||
</td>
|
||||
<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
|
||||
href="#"
|
||||
class="btn btn-primary test-action"
|
||||
|
@ -1,15 +1,16 @@
|
||||
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
|
||||
from ..models import Dataset, Entry, Test, User
|
||||
from ..tools.auth import disable_if_logged_in, require_account_creation
|
||||
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
|
||||
from ..tools.data import check_dataset_exists, check_is_json, validate_json
|
||||
from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
|
||||
from ..tools.logs import write
|
||||
from ..tools.test import answer_options, get_correct_answers
|
||||
|
||||
from flask import abort, Blueprint, jsonify, render_template, redirect, request, send_file, session
|
||||
from flask.helpers import flash, url_for
|
||||
from flask import abort, Blueprint, jsonify, render_template, request, send_file, session
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from datetime import date, datetime, MINYEAR, timedelta
|
||||
from json import loads
|
||||
from os import path
|
||||
import secrets
|
||||
@ -26,21 +27,29 @@ admin = Blueprint(
|
||||
@admin.route('/dashboard/')
|
||||
@login_required
|
||||
def _home():
|
||||
tests = Test.query.all()
|
||||
results = Entry.query.all()
|
||||
try:
|
||||
tests = Test.query.all()
|
||||
results = Entry.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
current_tests = [ test for test in tests if test.end_date >= datetime.now() and test.start_date.date() <= date.today() ]
|
||||
current_tests.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.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.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)
|
||||
|
||||
@admin.route('/settings/')
|
||||
@login_required
|
||||
def _settings():
|
||||
users = User.query.all()
|
||||
datasets = Dataset.query.all()
|
||||
try:
|
||||
users = User.query.all()
|
||||
datasets = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/settings/index.html', users=users, datasets=datasets)
|
||||
|
||||
@admin.route('/login/', methods=['GET','POST'])
|
||||
@ -50,7 +59,10 @@ def _login():
|
||||
form = Login()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
users = User.query.all()
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
user = None
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username').lower():
|
||||
@ -99,7 +111,10 @@ def _reset():
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
user = None
|
||||
users = User.query.all()
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
for _user in users:
|
||||
if _user.get_username() == request.form.get('username'):
|
||||
user = _user
|
||||
@ -111,7 +126,10 @@ def _reset():
|
||||
|
||||
token = request.args.get('token')
|
||||
if token:
|
||||
user = User.query.filter_by(reset_token=token).first()
|
||||
try: user = User.query.filter_by(reset_token=token).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not user: return redirect(url_for('admin._reset'))
|
||||
verification_token = user.verification_token
|
||||
user.clear_reset_tokens()
|
||||
@ -128,7 +146,10 @@ def _update_password():
|
||||
form = UpdatePassword()
|
||||
if form.validate_on_submit():
|
||||
user = session.pop('user')
|
||||
user = User.query.filter_by(id=user).first()
|
||||
try: user = User.query.filter_by(id=user).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
user.update(password=request.form.get('password'))
|
||||
session['remembered_username'] = user.get_username()
|
||||
flash('Your password has been reset.', 'success')
|
||||
@ -139,7 +160,10 @@ def _update_password():
|
||||
@login_required
|
||||
def _users():
|
||||
form = CreateUser()
|
||||
users = User.query.all()
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
password = request.form.get('password')
|
||||
@ -156,7 +180,10 @@ def _users():
|
||||
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _delete_user(id:str):
|
||||
user = User.query.filter_by(id=id).first()
|
||||
try: user = User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = DeleteUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
@ -180,12 +207,15 @@ def _delete_user(id:str):
|
||||
@admin.route('/settings/users/update/<string:id>', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def _update_user(id:str):
|
||||
user = User.query.filter_by(id=id).first()
|
||||
try: user = User.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = UpdateUser()
|
||||
if request.method == 'POST':
|
||||
if not user: return jsonify({'error': 'User does not exist.'}), 400
|
||||
if form.validate_on_submit():
|
||||
if not user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||
if not current_user.verify_password(request.form.get('confirm_password')): return jsonify({'error': 'Invalid password for your account.'}), 401
|
||||
success, message = user.update(
|
||||
password = request.form.get('password'),
|
||||
email = request.form.get('email'),
|
||||
@ -221,8 +251,10 @@ def _questions():
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
return send_errors_to_client(form=form)
|
||||
|
||||
data = Dataset.query.all()
|
||||
try: data = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/settings/questions.html', form=form, data=data)
|
||||
|
||||
@admin.route('/settings/questions/delete/', methods=['POST'])
|
||||
@ -231,7 +263,10 @@ def _edit_questions():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if not action == 'delete': return jsonify({'error': 'Invalid action.'}), 400
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if action == 'delete': success, message = dataset.delete()
|
||||
if success: return jsonify({'success': message}), 200
|
||||
return jsonify({'error': message}), 400
|
||||
@ -239,10 +274,13 @@ def _edit_questions():
|
||||
@admin.route('/settings/questions/download/<string:id>/')
|
||||
@login_required
|
||||
def _download(id:str):
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return abort(404)
|
||||
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/', methods=['GET'])
|
||||
@ -250,7 +288,10 @@ def _download(id:str):
|
||||
@check_dataset_exists
|
||||
def _tests(filter:str=None):
|
||||
tests = None
|
||||
_tests = Test.query.all()
|
||||
try: _tests = Test.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
form = None
|
||||
now = datetime.now()
|
||||
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||
@ -267,7 +308,7 @@ def _tests(filter:str=None):
|
||||
if filter in [None, '', 'active']:
|
||||
tests = [ test for test in _tests if test.end_date >= now and test.start_date <= now ]
|
||||
display_title = 'Active Exams'
|
||||
error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.'
|
||||
error_none = 'There are no exams that are currently active. You can create one using the Create Exam form.'
|
||||
if filter == 'expired':
|
||||
tests = [ test for test in _tests if test.end_date < now ]
|
||||
display_title = 'Expired Exams'
|
||||
@ -296,7 +337,10 @@ def _create_test():
|
||||
new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%dT%H:%M')
|
||||
new_test.time_limit = None if request.form.get('time_limit') == 'none' else int(request.form.get('time_limit'))
|
||||
dataset = request.form.get('dataset')
|
||||
new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||
try: new_test.dataset = Dataset.query.filter_by(id=dataset).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
success, message = new_test.create()
|
||||
if success:
|
||||
flash(message=message, category='success')
|
||||
@ -310,7 +354,10 @@ def _edit_test():
|
||||
id = request.get_json()['id']
|
||||
action = request.get_json()['action']
|
||||
if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404
|
||||
if action == 'delete': success, message = test.delete()
|
||||
if action == 'start': success, message = test.start()
|
||||
@ -324,7 +371,10 @@ def _edit_test():
|
||||
@login_required
|
||||
def _view_test(id:str=None):
|
||||
form = AddTimeAdjustment()
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
if form.validate_on_submit():
|
||||
@ -341,7 +391,10 @@ def _view_test(id:str=None):
|
||||
@admin.route('/test/<string:id>/delete-adjustment/', methods=['POST'])
|
||||
@login_required
|
||||
def _delete_adjustment(id:str=None):
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
try: test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Invalid test ID.'}), 404
|
||||
user_code = request.get_json()['user_code'].lower()
|
||||
success, message = test.remove_adjustment(user_code)
|
||||
@ -351,13 +404,19 @@ def _delete_adjustment(id:str=None):
|
||||
@admin.route('/results/')
|
||||
@login_required
|
||||
def _view_entries():
|
||||
entries = Entry.query.all()
|
||||
try: entries = Entry.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/admin/results.html', entries = entries)
|
||||
|
||||
@admin.route('/results/<string:id>/', methods = ['GET', 'POST'])
|
||||
@login_required
|
||||
def _view_entry(id:str=None):
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if request.method == 'POST':
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
action = request.get_json()['action']
|
||||
@ -375,10 +434,7 @@ def _view_entry(id:str=None):
|
||||
flash('Invalid entry ID.', 'error')
|
||||
return redirect(url_for('admin._view_entries'))
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
dataset_path = dataset.get_file()
|
||||
with open(dataset_path, 'r') as _dataset:
|
||||
data = loads(_dataset.read())
|
||||
data = test.dataset.get_data()
|
||||
correct = get_correct_answers(dataset=data)
|
||||
answers = answer_options(dataset=data)
|
||||
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)
|
||||
@ -388,6 +444,9 @@ def _view_entry(id:str=None):
|
||||
def _generate_certificate():
|
||||
from ..extensions import db
|
||||
id = request.get_json()['id']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return render_template('/admin/components/certificate.html', entry = entry)
|
0
ref-test/app/analysis/__init__.py
Normal file
0
ref-test/app/analysis/__init__.py
Normal file
8
ref-test/app/analysis/static/css/analysis.css
Normal file
8
ref-test/app/analysis/static/css/analysis.css
Normal file
@ -0,0 +1,8 @@
|
||||
#alert-box {
|
||||
margin: 30px auto;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.cell-percentage::after {
|
||||
content: '%';
|
||||
}
|
260
ref-test/app/analysis/static/css/style.css
Normal file
260
ref-test/app/analysis/static/css/style.css
Normal file
@ -0,0 +1,260 @@
|
||||
body {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background-color: lightgray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-ms-flex-align: center;
|
||||
align-items: center;
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.form-display {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 15px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.form-heading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-label-group input,
|
||||
.form-label-group label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
}
|
||||
|
||||
.form-label-group label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.form-label-group input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0%;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.form-label-group input:active, .form-label-group input:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-webkit-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::-moz-placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) {
|
||||
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
}
|
||||
|
||||
.form-label-group input:not(:placeholder-shown) ~ label {
|
||||
padding-top: calc(var(--input-padding-y) / 3);
|
||||
padding-bottom: calc(var(--input-padding-y) / 3);
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
transform: scale(1.5);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.signin-forgot-password {
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.form-submission-button {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-submission-button button, .form-submission-button a {
|
||||
margin: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
table.dataTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dataTables_wrapper .dt-buttons {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
float:none;
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.row-actions button, .row-actions a {
|
||||
margin: 0px 5px;
|
||||
}
|
||||
|
||||
#cookie-alert {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#dismiss-cookie-alert {
|
||||
margin-top: 16px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.alert-db-empty {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
font-size: 14pt;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.form-date-input, .form-select-input {
|
||||
position: relative;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.form-date-input input,
|
||||
.form-date-input label, .form-select-input select, .form-select-input label {
|
||||
padding: var(--input-padding-y) var(--input-padding-x);
|
||||
font-size: 16pt;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid #585858;
|
||||
}
|
||||
|
||||
.datepicker::-webkit-calendar-picker-indicator {
|
||||
border: 1px;
|
||||
border-color: gray;
|
||||
border-radius: 10%;
|
||||
}
|
||||
|
||||
.form-date-input label, .form-select-input label {
|
||||
/* position: absolute; */
|
||||
/* top: 0;
|
||||
left: 0; */
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 0; /* Override default `<label>` margin */
|
||||
line-height: 1.5;
|
||||
color: #495057;
|
||||
cursor: text; /* Match the input under the label */
|
||||
border: 1px solid transparent;
|
||||
border-radius: .25rem;
|
||||
transition: all .1s ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.form-upload {
|
||||
margin: 2rem 0;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.result-action-buttons, .test-action {
|
||||
margin: 5px auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
/* Change Autocomplete styles in Chrome*/
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill,
|
||||
textarea:-webkit-autofill:hover,
|
||||
textarea:-webkit-autofill:focus,
|
||||
select:-webkit-autofill,
|
||||
select:-webkit-autofill:hover,
|
||||
select:-webkit-autofill:focus {
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Fallback for Edge
|
||||
-------------------------------------------------- */
|
||||
@supports (-ms-ime-align: auto) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input::-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for IE
|
||||
-------------------------------------------------- */
|
||||
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
|
||||
.form-label-group label {
|
||||
display: none;
|
||||
}
|
||||
.form-label-group input:-ms-input-placeholder {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
27
ref-test/app/analysis/static/js/analysis.js
Normal file
27
ref-test/app/analysis/static/js/analysis.js
Normal file
@ -0,0 +1,27 @@
|
||||
// Analyse Button Listener
|
||||
$('.button-analyse').click(function(event) {
|
||||
|
||||
let buttonClass = $(this).data('class')
|
||||
let id = null
|
||||
|
||||
if (buttonClass == 'test' ) {
|
||||
id = $('#select-test').children('option:selected').val()
|
||||
} else if (buttonClass == 'dataset' ) {
|
||||
id = $('#select-dataset').children('option:selected').val()
|
||||
}
|
||||
|
||||
$.ajax({
|
||||
url: `/admin/analysis/`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({'id': id, 'class': buttonClass}),
|
||||
contentType: 'application/json',
|
||||
success: function(response) {
|
||||
window.location.href = response
|
||||
},
|
||||
error: function(response){
|
||||
error_response(response)
|
||||
},
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
})
|
2
ref-test/app/analysis/static/js/jquery-3.6.0.min.js
vendored
Normal file
2
ref-test/app/analysis/static/js/jquery-3.6.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
115
ref-test/app/analysis/static/js/script.js
Normal file
115
ref-test/app/analysis/static/js/script.js
Normal file
@ -0,0 +1,115 @@
|
||||
// Menu Highlight Scripts
|
||||
const menuItems = document.getElementsByClassName('nav-link')
|
||||
for(let i = 0; i < menuItems.length; i++) {
|
||||
if(menuItems[i].pathname == window.location.pathname) {
|
||||
menuItems[i].classList.add('active')
|
||||
}
|
||||
}
|
||||
const dropdownItems = document.getElementsByClassName('dropdown-item')
|
||||
for(let i = 0; i< dropdownItems.length; i++) {
|
||||
if(dropdownItems[i].pathname == window.location.pathname) {
|
||||
dropdownItems[i].classList.add('active')
|
||||
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
|
||||
}
|
||||
}
|
||||
|
||||
// General Post Method Form Processing Script
|
||||
$('form.form-post').submit(function(event) {
|
||||
|
||||
var $form = $(this)
|
||||
var data = $form.serialize()
|
||||
var url = $(this).prop('action')
|
||||
var rel_success = $(this).data('rel-success')
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'POST',
|
||||
data: data,
|
||||
dataType: 'json',
|
||||
success: function(response) {
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to
|
||||
}
|
||||
else {
|
||||
window.location.href = rel_success
|
||||
}
|
||||
},
|
||||
error: function(response) {
|
||||
error_response(response)
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
function error_response(response) {
|
||||
|
||||
const $alert = $("#alert-box")
|
||||
$alert.html('')
|
||||
|
||||
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
|
||||
$alert.html(`
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`)
|
||||
} else if (response.responseJSON.error instanceof Array) {
|
||||
var output = ''
|
||||
for (let i = 0; i < response.responseJSON.error.length; i ++) {
|
||||
output += `
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
|
||||
${response.responseJSON.error[i]}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`
|
||||
$alert.html(output)
|
||||
}
|
||||
}
|
||||
|
||||
$alert.focus()
|
||||
}
|
||||
|
||||
// Dismiss Cookie Alert
|
||||
$('#dismiss-cookie-alert').click(function(event){
|
||||
|
||||
$.ajax({
|
||||
url: '/cookies/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
console.log(response)
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
// Create New Dataset
|
||||
$('.create-new-dataset').click(function(event){
|
||||
$.ajax({
|
||||
url: '/api/editor/new/',
|
||||
type: 'POST',
|
||||
data: {
|
||||
time: Date.now()
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(response){
|
||||
if (response.redirect_to) {
|
||||
window.location.href = response.redirect_to
|
||||
}
|
||||
},
|
||||
error: function(response){
|
||||
console.log(response)
|
||||
}
|
||||
})
|
||||
event.preventDefault()
|
||||
})
|
194
ref-test/app/analysis/templates/analysis/analysis.html
Normal file
194
ref-test/app/analysis/templates/analysis/analysis.html
Normal file
@ -0,0 +1,194 @@
|
||||
{% extends "analysis/components/datatable.html" %}
|
||||
|
||||
{% block style %}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/analysis.css') }}"
|
||||
/>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Analysis by {{ type[0]|upper }}{{ type[1:] }}</h1>
|
||||
<div class="container">
|
||||
<p class="lead">
|
||||
The analysis section displays statistics for all test results as well as answers to individual questions.
|
||||
Analysis reports can be generated per exam or per question dataset to identify common mistakes or patterns in answers.
|
||||
</p>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">
|
||||
{% if type == 'exam' %}
|
||||
Exam Code
|
||||
{% elif type == 'dataset' %}
|
||||
Dataset Name
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="form-control">
|
||||
{{ subject }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Total Entries</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.entries }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Passed</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.grades.merit + analysis.grades.pass }} ({{ ((analysis.grades.merit + analysis.grades.pass)*100/analysis.entries)|round(2) }} %)
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<span class="badge rounded-pill progress-bar-striped bg-success">Merit: {{ analysis.grades.merit }}</span> <span class="badge rounded-pill bg-primary progress-bar-striped">Pass: {{ analysis.grades.pass }}</span> <span class="badge rounded-pill progress-bar-striped bg-danger">Fail: {{ analysis.grades.fail }}</span>
|
||||
<div class="my-1 progress">
|
||||
<div class="progress-bar progress-bar-striped bg-success" role="progressbar" style="width: {{ (analysis.grades.merit*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.merit }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.merit*100/analysis.entries)|round(2) }} %</div>
|
||||
<div class="progress-bar progress-bar-striped" role="progressbar" style="width: {{ (analysis.grades.pass*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.pass }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.pass*100/analysis.entries)|round(2) }} %</div>
|
||||
<div class="progress-bar progress-bar-striped bg-danger" role="progressbar" style="width: {{ (analysis.grades.fail*100/analysis.entries)|round(2) }}%" aria-valuenow="{{ analysis.grades.fail }}" aria-valuemin="0" aria-valuemax="{{ analysis.entries }}">{{ (analysis.grades.fail*100/analysis.entries)|round(2) }} %</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Mean Score</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.scores.mean|round(2) }} %
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Standard Deviation</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.scores.stdev|round(2) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Median Score</span>
|
||||
<span class="form-control">
|
||||
{{ analysis.scores.median|round(2) }} %
|
||||
</span>
|
||||
</div>
|
||||
{% if type == 'exam' %}
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">Dataset Name</span>
|
||||
<span class="form-control">
|
||||
{{ dataset.get_name() }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="container">
|
||||
<table id="analysis-table" class="table table-striped" style="width:100%">
|
||||
<thead>
|
||||
<th data-priority="1">
|
||||
Question
|
||||
</th>
|
||||
<th data-priority="1">
|
||||
Percent Correct
|
||||
</th>
|
||||
<th data-priority="2">
|
||||
Answers
|
||||
</th>
|
||||
<th data-priority="3">
|
||||
Tags
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for question in questions %}
|
||||
<tr class="table-row">
|
||||
<td>
|
||||
{{ question.q_no + 1 }}
|
||||
</td>
|
||||
<td class="cell-percentage">
|
||||
{{ ((analysis.answers[question.q_no][question.correct] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}
|
||||
</td>
|
||||
<td>
|
||||
<table style="width:100%">
|
||||
{% for option in question.options %}
|
||||
<tr>
|
||||
<td style="width:50%">
|
||||
{{ option[1] }}
|
||||
</td>
|
||||
<td>
|
||||
{% if question.correct == option[0] %}
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="progress">
|
||||
<div class="progress-bar bg-danger progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
{% for tag in question.tags %}
|
||||
<li>{{ tag|safe }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const target = "{{ url_for('api._editor') }}"
|
||||
const id = "{{ dataset.id }}"
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||
></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block custom_data_script %}
|
||||
<script>
|
||||
console.log($('#analysis-table'))
|
||||
$(document).ready(function() {
|
||||
$('#analysis-table').DataTable({
|
||||
'searching': true,
|
||||
'columnDefs': [
|
||||
{'sortable': true, 'targets': [0,1]},
|
||||
{'sortable': false, 'targets': [2,3]},
|
||||
{'searchable': true, 'targets': [0,2,3]}
|
||||
],
|
||||
'order': [[0, 'asc'], [1, 'desc']],
|
||||
'buttons': [
|
||||
{
|
||||
extend: 'print',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'excel',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
},
|
||||
{
|
||||
extend: 'pdf',
|
||||
exportOptions: {
|
||||
columns: [0, 1, 2, 3]
|
||||
}
|
||||
}
|
||||
],
|
||||
'responsive': 'true',
|
||||
'colReorder': 'true',
|
||||
'fixedHeader': 'true',
|
||||
'searchBuilder': {
|
||||
depthLimit: 2,
|
||||
columns: [2, 3],
|
||||
},
|
||||
dom: 'BQlfrtip'
|
||||
});
|
||||
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
|
||||
} );
|
||||
$('#analysis-table').show();
|
||||
$(window).trigger('resize');
|
||||
</script>
|
||||
{% endblock %}
|
@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/style.css') }}"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('.static', filename='css/view.css') }}"
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
|
||||
{% include "analysis/components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% block navbar %}
|
||||
{% include "analysis/components/navbar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="container">
|
||||
{% block top_alerts %}
|
||||
{% include "analysis/components/server-alerts.html" %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="container site-footer mt-5">
|
||||
{% block footer %}
|
||||
{% include "analysis/components/footer.html" %}
|
||||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
<!-- JQuery, Popper, and Bootstrap js dependencies -->
|
||||
<script
|
||||
src="https://code.jquery.com/jquery-3.6.0.min.js"
|
||||
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
|
||||
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
|
||||
crossorigin="anonymous">
|
||||
</script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
|
||||
xhr.setRequestHeader("X-CSRFToken", csrf_token);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/script.js') }}"
|
||||
></script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="{{ url_for('.static', filename='js/analysis.js') }}"
|
||||
></script>
|
||||
{% block script %}
|
||||
{% endblock %}
|
||||
{% block datatable_scripts %}
|
||||
{% endblock %}
|
||||
{% block custom_data_script %}
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
@ -0,0 +1 @@
|
||||
<div id="alert-box" tabindex="-1"></div>
|
@ -0,0 +1,28 @@
|
||||
{% extends "analysis/components/base.html" %}
|
||||
{% block datatable_css %}
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
|
||||
{% endblock %}
|
||||
{% block datatable_scripts %}
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
|
||||
{% endblock %}
|
@ -0,0 +1,2 @@
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -0,0 +1,4 @@
|
||||
{% extends "analysis/components/base.html" %}
|
||||
{% import "bootstrap/wtf.html" as wtf %}
|
||||
{% block top_alerts %}
|
||||
{% endblock %}
|
137
ref-test/app/analysis/templates/analysis/components/navbar.html
Normal file
137
ref-test/app/analysis/templates/analysis/components/navbar.html
Normal file
@ -0,0 +1,137 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar"
|
||||
aria-controls="navbar"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle Navigation"
|
||||
>
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse justify-content-end" id="navbar">
|
||||
<ul class="navbar-nav">
|
||||
{% if not current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-login">
|
||||
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item dropdown" id="nav-results">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-results"
|
||||
role="button"
|
||||
href="{{ url_for('admin._view_entries') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Results
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-results"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-tests">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-tests"
|
||||
role="button"
|
||||
href="{{ url_for('admin._tests') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Exams
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-tests"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-settings">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-settings"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown" id="nav-account">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
role="button"
|
||||
href="{{ url_for('admin._update_user', id=current_user.id) }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
Account
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-account"
|
||||
>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
@ -0,0 +1,18 @@
|
||||
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:locale" content="en_UK" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
|
||||
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
|
||||
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta property="og:image:width" content="512" />
|
||||
<meta property="og:image:height" content="512" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
|
||||
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
|
||||
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
|
||||
<meta name="twitter:creator" content="@viveksantayana" />
|
||||
<meta name="twitter:site" content="@viveksantayana" />
|
||||
<meta name="theme-color" content="#343a40" />
|
||||
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">
|
@ -0,0 +1,23 @@
|
||||
<div class="navbar navbar-expand-sm navbar-light bg-light">
|
||||
<div class="container-fluid">
|
||||
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,43 @@
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% set cookie_flash_flag = namespace(value=False) %}
|
||||
{% for category, message in messages %}
|
||||
{% if category == "error" %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "success" %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "warning" %}
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% elif category == "cookie_alert" %}
|
||||
{% if not cookie_flash_flag.value %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
|
||||
{{ message|safe }}
|
||||
<div class="d-flex justify-content-center w-100">
|
||||
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set cookie_flash_flag.value = True %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-info-circle-fill" title="Alert"></i>
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
54
ref-test/app/analysis/templates/analysis/index.html
Normal file
54
ref-test/app/analysis/templates/analysis/index.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends "analysis/components/input-forms.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Analysis</h1>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<div class="card m-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Exams</h5>
|
||||
<div class="card-text">
|
||||
<div class="form-select-input">
|
||||
<select name="select-test" id="select-test">
|
||||
{% for test in tests %}
|
||||
<option value="{{ test.id }}">{{ test.get_code() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<a href="{{ url_for('analysis._test') }}" class="btn btn-primary button-analyse" data-class="test">
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
Analyse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<div class="card m-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Datasets</h5>
|
||||
<div class="card-text">
|
||||
<div class="form-select-input">
|
||||
<select name="select-dataset" id="select-dataset">
|
||||
{% for dataset in datasets %}
|
||||
<option value="{{ dataset.id }}">{{ dataset.get_name() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<a href="{{ url_for('analysis._dataset') }}" class="btn btn-primary button-analyse" data-class="dataset">
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
Analyse
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include "analysis/components/client-alerts.html" %}
|
||||
{% endblock %}
|
85
ref-test/app/analysis/views.py
Normal file
85
ref-test/app/analysis/views.py
Normal file
@ -0,0 +1,85 @@
|
||||
from ..models import Dataset, Test
|
||||
from ..tools.data import analyse, check_dataset_exists, check_test_exists
|
||||
from ..tools.logs import write
|
||||
from ..tools.data import parse_questions
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
analysis = Blueprint(
|
||||
name='analysis',
|
||||
import_name=__name__,
|
||||
template_folder='templates',
|
||||
static_folder='static'
|
||||
)
|
||||
|
||||
@analysis.route('/', methods=['GET','POST'])
|
||||
@login_required
|
||||
@check_dataset_exists
|
||||
@check_test_exists
|
||||
def _analysis():
|
||||
try:
|
||||
_tests = Test.query.all()
|
||||
_datasets = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
tests = [ test for test in _tests if test.entries ]
|
||||
datasets = [ dataset for dataset in _datasets if dataset.entries ]
|
||||
if request.method == 'POST':
|
||||
selection = request.get_json()
|
||||
if selection['class'] == 'test':
|
||||
try:
|
||||
test = Test.query.filter_by(id=selection['id']).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return url_for('analysis._test', id=selection['id']), 200
|
||||
if selection['class'] == 'dataset':
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=selection['id']).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return url_for('analysis._dataset', id=selection['id']), 200
|
||||
return jsonify({'error': 'Invalid entry ID.'}), 404
|
||||
return render_template('/analysis/index.html', tests=tests, datasets=datasets)
|
||||
|
||||
@analysis.route('/test/<string:id>')
|
||||
@analysis.route('/test/')
|
||||
@login_required
|
||||
@check_test_exists
|
||||
def _test(id:str=None):
|
||||
if id in [None, '']:
|
||||
flash(message='Please select a valid exam.', category='error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
try:
|
||||
test = Test.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not test:
|
||||
flash('Invalid exam.', 'error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
return render_template('/analysis/analysis.html', analysis=analyse(test), subject=test.get_code(), type='exam', dataset=test.dataset, questions=parse_questions(test.dataset.get_data()))
|
||||
|
||||
@analysis.route('/dataset/<string:id>')
|
||||
@analysis.route('/dataset/')
|
||||
@login_required
|
||||
@check_dataset_exists
|
||||
def _dataset(id:str=None):
|
||||
if id in [None, '']:
|
||||
flash(message='Please select a valid dataset.', category='error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset:
|
||||
flash('Invalid dataset.', 'error')
|
||||
return redirect(url_for('analysis._analysis'))
|
||||
return render_template('/analysis/analysis.html', analysis=analyse(dataset), subject=dataset.get_name(), type='dataset', dataset=dataset, questions=parse_questions(dataset.get_data()))
|
@ -1,8 +1,10 @@
|
||||
from ..models import Dataset, Entry, User
|
||||
from ..tools.data import validate_json
|
||||
from ..tools.logs import write
|
||||
from ..tools.test import evaluate_answers, generate_questions
|
||||
|
||||
from flask import Blueprint, flash, jsonify, request, url_for
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask.helpers import abort, flash, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
@ -16,7 +18,10 @@ api = Blueprint(
|
||||
@api.route('/questions/', methods=['POST'])
|
||||
def _fetch_questions():
|
||||
id = request.get_json()['id']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400
|
||||
test = entry.test
|
||||
user_code = entry.user_code
|
||||
@ -50,7 +55,10 @@ def _fetch_questions():
|
||||
def _submit_quiz():
|
||||
id = request.get_json()['id']
|
||||
answers = request.get_json()['answers']
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return jsonify({'error': 'Unrecognised Entry.'}), 400
|
||||
test = entry.test
|
||||
dataset = test.dataset
|
||||
@ -71,7 +79,10 @@ def _submit_quiz():
|
||||
def _editor(id:str=None):
|
||||
request_data = request.get_json()
|
||||
id = request_data['id']
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
try: dataset = Dataset.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset: return jsonify({'error': 'Invalid request. Dataset not found.'}), 404
|
||||
data_path = dataset.get_file()
|
||||
if request_data['action'] == 'fetch':
|
||||
@ -80,10 +91,13 @@ def _editor(id:str=None):
|
||||
return jsonify({'success': 'Successfully downloaded dataset', 'data': data}), 200
|
||||
default = request_data['default']
|
||||
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']
|
||||
data = request_data['data']
|
||||
if not validate_json(data): return jsonify({'error': 'The data you submitted was invalid.'}), 400
|
||||
user = User.query.filter_by(id=creator).first()
|
||||
dataset.set_name(name)
|
||||
dataset.creator = user
|
||||
success, message = dataset.update(data=data, default=default)
|
||||
|
@ -4,6 +4,7 @@ from dotenv import load_dotenv
|
||||
load_dotenv('../.env')
|
||||
|
||||
class Config(object):
|
||||
"""Basic App Configuration"""
|
||||
APP_HOST = '0.0.0.0'
|
||||
DATA = './data/'
|
||||
DEBUG = False
|
||||
@ -11,9 +12,9 @@ class Config(object):
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
SERVER_NAME = os.getenv('SERVER_NAME')
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/database.db"))}'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
WTF_CSRF_TIME_LIMIT = None
|
||||
|
||||
"""Email Engine Configuration"""
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT') or 25)
|
||||
MAIL_USE_TLS = False
|
||||
@ -26,6 +27,19 @@ class Config(object):
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS') or True)
|
||||
|
||||
"""Database Driver Configuration"""
|
||||
DATABASE_TYPE = os.getenv('DATABASE_TYPE') or 'SQLite'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
if DATABASE_TYPE.lower() == 'mysql' and os.getenv('MYSQL_DATABASE') and os.getenv('MYSQL_USER') and os.getenv('MYSQL_PASSWORD'):
|
||||
DATABASE_HOST = os.getenv('DATABASE_HOST') or 'localhost'
|
||||
DATABASE_PORT = int(os.getenv('DATABASE_PORT') or 3306)
|
||||
MYSQL_DATABASE = os.getenv('MYSQL_DATABASE')
|
||||
MYSQL_USER = os.getenv('MYSQL_USER')
|
||||
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD')
|
||||
SQLALCHEMY_DATABASE_URI = f'mysql+pymysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{DATABASE_HOST}:{DATABASE_PORT}/{MYSQL_DATABASE}'
|
||||
else: SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/db.sqlite"))}'
|
||||
|
||||
class Production(Config):
|
||||
pass
|
||||
|
||||
|
@ -1,2 +1,2 @@
|
||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -1,6 +1,6 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
@ -20,8 +20,28 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-results">
|
||||
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
||||
<li 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
|
||||
@ -36,7 +56,7 @@
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
aria-labelledby="dropdown-tests"
|
||||
>
|
||||
<li>
|
||||
<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">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
id="dropdown-settings"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
|
@ -1,10 +1,11 @@
|
||||
from ..forms.admin import EditDataset
|
||||
from ..models import Dataset, User
|
||||
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||
from ..tools.data import check_dataset_exists
|
||||
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request
|
||||
from flask.helpers import url_for
|
||||
from flask import Blueprint, jsonify, render_template
|
||||
from flask.helpers import abort, flash, redirect, request, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
editor = Blueprint(
|
||||
@ -31,9 +32,13 @@ def _editor():
|
||||
@check_dataset_exists
|
||||
@login_required
|
||||
def _editor_console(id:str=None):
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
datasets = Dataset.query.count()
|
||||
users = User.query.all()
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
datasets = Dataset.query.count()
|
||||
users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset:
|
||||
flash('Invalid dataset ID.', 'error')
|
||||
return redirect(url_for('admin._questions'))
|
||||
|
@ -2,23 +2,22 @@ from ..extensions import db
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import flash
|
||||
from flask import current_app as app
|
||||
from flask.helpers import flash
|
||||
from flask_login import current_user
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from datetime import datetime
|
||||
from json import dump
|
||||
from json import dump, loads
|
||||
from os import path, remove
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
class Dataset(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
tests = db.relationship('Test', backref='dataset')
|
||||
entries = db.relationship('Entry', backref='dataset')
|
||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||
date = db.Column(db.DateTime, nullable=False)
|
||||
default = db.Column(db.Boolean, default=False, nullable=True)
|
||||
@ -43,12 +42,14 @@ class Dataset(db.Model):
|
||||
def get_name(self): return decrypt(self.name)
|
||||
|
||||
def make_default(self):
|
||||
for dataset in Dataset.query.all():
|
||||
dataset.default = False
|
||||
self.default = True
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
for dataset in Dataset.query.all(): dataset.default = False
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
self.default = True
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
@ -61,10 +62,14 @@ class Dataset(db.Model):
|
||||
message = 'Cannot delete the default dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
if Dataset.query.count() == 1:
|
||||
message = 'Cannot delete the only dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
try:
|
||||
if Dataset.query.count() == 1:
|
||||
message = 'Cannot delete the only dataset.'
|
||||
flash(message, 'error')
|
||||
return False, message
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
|
||||
filename = secure_filename('.'.join([self.id,'json']))
|
||||
data = Path(app.config.get('DATA'))
|
||||
@ -72,7 +77,7 @@ class Dataset(db.Model):
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when trying to delete dataset {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -92,7 +97,7 @@ class Dataset(db.Model):
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when trying to crreate dataset {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -111,6 +116,12 @@ class Dataset(db.Model):
|
||||
file_path = path.join(data, 'questions', filename)
|
||||
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):
|
||||
self.date = datetime.now()
|
||||
if default: self.make_default()
|
||||
@ -122,7 +133,7 @@ class Dataset(db.Model):
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when trying to update dataset {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
|
@ -1,32 +1,31 @@
|
||||
from ..extensions import db, mail
|
||||
from ..tools.forms import JsonEncodedDict
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
from .test import Test
|
||||
from .dataset import Dataset
|
||||
|
||||
from flask_login import current_user
|
||||
from flask_mail import Message
|
||||
from smtplib import SMTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy_json import MutableJson
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
class Entry(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||
first_name = db.Column(db.String(128), nullable=False)
|
||||
surname = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(128), nullable=False)
|
||||
club = db.Column(db.String(128), nullable=True)
|
||||
test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
|
||||
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
||||
user_code = db.Column(db.String(6), nullable=True)
|
||||
start_time = db.Column(db.DateTime, nullable=True)
|
||||
end_time = db.Column(db.DateTime, nullable=True)
|
||||
start_time = db.Column(db.DateTime, index=True, nullable=True)
|
||||
end_time = db.Column(db.DateTime, index=True, nullable=True)
|
||||
status = db.Column(db.String(16), nullable=True)
|
||||
valid = db.Column(db.Boolean, default=True, nullable=True)
|
||||
answers = db.Column(JsonEncodedDict, nullable=True)
|
||||
result = db.Column(JsonEncodedDict, nullable=True)
|
||||
answers = db.Column(MutableJson, nullable=True)
|
||||
result = db.Column(MutableJson, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
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:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when preparing new entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -84,9 +83,8 @@ class Entry(db.Model):
|
||||
def start(self):
|
||||
self.start_time = datetime.now()
|
||||
self.status = 'started'
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when starting test for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -104,9 +102,8 @@ class Entry(db.Model):
|
||||
else:
|
||||
self.status = 'late'
|
||||
self.valid = False
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when submitting entry for {self.get_surname()}, {self.get_first_name()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -118,9 +115,8 @@ class Entry(db.Model):
|
||||
if self.status == 'started': return False, 'The entry is still pending.'
|
||||
self.valid = True
|
||||
self.status = 'completed'
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when validating entry {self.id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -133,7 +129,7 @@ class Entry(db.Model):
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting entry {id}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -201,7 +197,5 @@ class Entry(db.Model):
|
||||
<p>Best wishes, <br/> SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
try:
|
||||
mail.send(email)
|
||||
except SMTPException as exception:
|
||||
write('system.log', f'SMTP Error when trying to notify results to {self.get_surname()}, {self.get_first_name()} with error: {exception}')
|
||||
try: mail.send(email)
|
||||
except Exception as exception: write('system.log', f'SMTP Error when trying to notify results to {self.get_surname()}, {self.get_first_name()} with error: {exception}')
|
@ -1,24 +1,22 @@
|
||||
from ..extensions import db
|
||||
from ..tools.forms import JsonEncodedDict
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask_login import current_user
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy_json import MutableJson
|
||||
|
||||
from datetime import date, datetime
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
|
||||
class Test(db.Model):
|
||||
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
code = db.Column(db.String(36), nullable=False)
|
||||
start_date = db.Column(db.DateTime, nullable=True)
|
||||
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||
code = db.Column(db.String(36), index=True, nullable=False)
|
||||
start_date = db.Column(db.DateTime, index=True, nullable=True)
|
||||
end_date = db.Column(db.DateTime, nullable=True)
|
||||
time_limit = db.Column(db.Integer, nullable=True)
|
||||
creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
|
||||
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
|
||||
adjustments = db.Column(JsonEncodedDict, nullable=True)
|
||||
adjustments = db.Column(MutableJson, nullable=True)
|
||||
entries = db.relationship('Entry', backref='test')
|
||||
|
||||
def __repr__(self):
|
||||
@ -56,7 +54,7 @@ class Test(db.Model):
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when creating test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -66,9 +64,8 @@ class Test(db.Model):
|
||||
def delete(self):
|
||||
if self.entries: return False, f'Cannot delete a test with submitted entries.'
|
||||
db.session.delete(self)
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -79,9 +76,8 @@ class Test(db.Model):
|
||||
now = datetime.now()
|
||||
if self.start_date.date() > now.date():
|
||||
self.start_date = now
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when launching test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -93,9 +89,8 @@ class Test(db.Model):
|
||||
now = datetime.now()
|
||||
if self.end_date >= now:
|
||||
self.end_date = now
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when closing test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -108,9 +103,8 @@ class Test(db.Model):
|
||||
code = secrets.token_hex(3).lower()
|
||||
adjustments[code] = time
|
||||
self.adjustments = adjustments
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when adding adjustment to test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -121,9 +115,8 @@ class Test(db.Model):
|
||||
if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.'
|
||||
self.adjustments.pop(code)
|
||||
if not self.adjustments: self.adjustments = None
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting adjustment from test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -135,9 +128,8 @@ class Test(db.Model):
|
||||
if start_date: self.start_date = start_date
|
||||
if end_date: self.end_date = end_date
|
||||
if time_limit is not None: self.time_limit = time_limit
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when updating test {self.get_code()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
|
@ -2,22 +2,20 @@ from ..extensions import db, mail
|
||||
from ..tools.encryption import decrypt, encrypt
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import flash, jsonify, session
|
||||
from flask.helpers import url_for
|
||||
from flask import jsonify, session
|
||||
from flask.helpers import flash, url_for
|
||||
from flask_login import current_user, login_user, logout_user, UserMixin
|
||||
from flask_mail import Message
|
||||
from smtplib import SMTPException
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
import secrets
|
||||
from uuid import uuid4
|
||||
class User(UserMixin, db.Model):
|
||||
id = db.Column(db.String(36), primary_key=True)
|
||||
id = db.Column(db.String(36), index=True, primary_key=True)
|
||||
username = db.Column(db.String(128), nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False)
|
||||
email = db.Column(db.String(128), nullable=False)
|
||||
reset_token = db.Column(db.String(20), nullable=True)
|
||||
reset_token = db.Column(db.String(20), index=True, nullable=True)
|
||||
verification_token = db.Column(db.String(20), nullable=True)
|
||||
tests = db.relationship('Test', backref='creator')
|
||||
datasets = db.relationship('Dataset', backref='creator')
|
||||
@ -57,7 +55,10 @@ class User(UserMixin, db.Model):
|
||||
|
||||
def register(self, notify:bool=False, password:str=None):
|
||||
self.generate_id()
|
||||
users = User.query.all()
|
||||
try: users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
for user in users:
|
||||
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.'
|
||||
if user.get_email() == self.get_email(): return False, f'Email address {self.get_email()} already in use.'
|
||||
@ -65,7 +66,7 @@ class User(UserMixin, db.Model):
|
||||
try:
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when registering user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -97,10 +98,8 @@ class User(UserMixin, db.Model):
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
try:
|
||||
mail.send(email)
|
||||
except SMTPException as exception:
|
||||
write('system.log', f'SMTP Error while trying to notify new user account creation to {self.get_username()} with error: {exception}')
|
||||
try: mail.send(email)
|
||||
except Exception as exception: write('system.log', f'SMTP Error while trying to notify new user account creation to {self.get_username()} with error: {exception}')
|
||||
return True, f'User {self.get_username()} was created successfully.'
|
||||
|
||||
def login(self, remember:bool=False):
|
||||
@ -151,15 +150,13 @@ class User(UserMixin, db.Model):
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
try:
|
||||
mail.send(email)
|
||||
except SMTPException as exception:
|
||||
try: mail.send(email)
|
||||
except Exception as exception:
|
||||
write('system.log', f'SMTP Error while trying to reset password for {self.get_username()} with error: {exception}')
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'SMTP Error: {exception}'}), 500
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when resetting password for user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -167,9 +164,8 @@ class User(UserMixin, db.Model):
|
||||
|
||||
def clear_reset_tokens(self):
|
||||
self.reset_token = self.verification_token = None
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when resetting clearing reset tokens for user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -180,7 +176,7 @@ class User(UserMixin, db.Model):
|
||||
try:
|
||||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when deleting user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -208,10 +204,8 @@ class User(UserMixin, db.Model):
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
try:
|
||||
mail.send(email)
|
||||
except SMTPException as exception:
|
||||
write('system.log', f'SMTP Error when trying to delete account {username} with error: {exception}')
|
||||
try: mail.send(email)
|
||||
except Exception as exception: write('system.log', f'SMTP Error when trying to delete account {username} with error: {exception}')
|
||||
return True, message
|
||||
|
||||
def update(self, password:str=None, email:str=None, notify:bool=False):
|
||||
@ -219,12 +213,15 @@ class User(UserMixin, db.Model):
|
||||
if password: self.set_password(password)
|
||||
old_email = self.get_email()
|
||||
if email:
|
||||
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.'
|
||||
try:
|
||||
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.'
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when setting default dataset {self.id}: {exception}')
|
||||
return False, f'Database error {exception}.'
|
||||
self.set_email(email)
|
||||
try:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as exception:
|
||||
try: db.session.commit()
|
||||
except Exception as exception:
|
||||
db.session.rollback()
|
||||
write('system.log', f'Database error when updating user {self.get_username()}: {exception}')
|
||||
return False, f'Database error: {exception}'
|
||||
@ -257,8 +254,6 @@ class User(UserMixin, db.Model):
|
||||
<p>SKA Refereeing</p>
|
||||
"""
|
||||
)
|
||||
try:
|
||||
mail.send(message)
|
||||
except SMTPException as exception:
|
||||
write('system.log', f'SMTP Error when trying to update account {self.get_username()} with error: {exception}')
|
||||
try: mail.send(message)
|
||||
except Exception as exception: write('system.log', f'SMTP Error when trying to update account {self.get_username()} with error: {exception}')
|
||||
return True, f'Account {self.get_username()} has been updated.'
|
||||
|
@ -14,6 +14,9 @@
|
||||
<div class="container quiz-start-text">
|
||||
You can use this panel to adjust the display settings for the exam. Please use the menu below to select the font face and font size. Below is a sample question so you can see how the exam will render with your chosen settings.
|
||||
</div>
|
||||
<div class="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">
|
||||
<strong>Note</strong>: Some fonts may not be available depending on your device and/or operating system.
|
||||
</div>
|
||||
|
@ -17,7 +17,7 @@
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
||||
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
||||
{% include "quiz/components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
@ -56,6 +56,8 @@
|
||||
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- jQuery UI -->
|
||||
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
|
||||
<!-- Custom js -->
|
||||
<script type="text/javascript">
|
||||
var csrf_token = "{{ csrf_token() }}";
|
||||
|
@ -1,3 +1,3 @@
|
||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
||||
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
@ -1,6 +1,6 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
||||
<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="d-flex justify-content align-middle">
|
||||
<div class="container d-flex justify-content-center">
|
||||
|
@ -1,6 +1,10 @@
|
||||
{% extends "quiz/components/base.html" %}
|
||||
{% 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 %}
|
||||
<div class="form-container">
|
||||
<form name="form-quiz-start" class="form-quiz-start">
|
||||
@ -44,3 +48,13 @@
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
$( function() {
|
||||
const clubs = {{ clubs|tojson }}
|
||||
$('#club').autocomplete({
|
||||
source: clubs
|
||||
})
|
||||
} )
|
||||
</script>
|
||||
{% endblock %}
|
@ -1,10 +1,11 @@
|
||||
from ..forms.quiz import StartQuiz
|
||||
from ..models import Entry, Test
|
||||
from ..tools.forms import send_errors_to_client
|
||||
from ..tools.logs import write
|
||||
from ..tools.test import redirect_if_started
|
||||
|
||||
from flask import abort, Blueprint, jsonify, redirect, render_template, request, session
|
||||
from flask.helpers import flash, url_for
|
||||
from flask import Blueprint, jsonify, render_template, request, session
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@ -28,6 +29,23 @@ def _instructions():
|
||||
|
||||
@quiz.route('/start/', methods=['GET', 'POST'])
|
||||
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()
|
||||
if request.method == 'POST':
|
||||
if form.validate_on_submit():
|
||||
@ -37,8 +55,12 @@ def _start():
|
||||
entry.set_club(request.form.get('club'))
|
||||
entry.set_email(request.form.get('email'))
|
||||
code = request.form.get('test_code').replace('—', '').lower()
|
||||
test = Test.query.filter_by(code=code).first()
|
||||
try: test = Test.query.filter_by(code=code).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
entry.test = test
|
||||
entry.dataset = test.dataset
|
||||
entry.user_code = request.form.get('user_code')
|
||||
entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
|
||||
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400
|
||||
@ -54,21 +76,28 @@ def _start():
|
||||
}), 200
|
||||
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
|
||||
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/')
|
||||
def _quiz():
|
||||
id = session.get('id')
|
||||
if not id or not Entry.query.filter_by(id=id).first():
|
||||
flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
|
||||
session.pop('id', None)
|
||||
return redirect(url_for('quiz._start'))
|
||||
try:
|
||||
if not id or not Entry.query.filter_by(id=id).first():
|
||||
flash('Your session was not recognised. Please sign in to the quiz again.', 'error')
|
||||
session.pop('id', None)
|
||||
return redirect(url_for('quiz._start'))
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
return render_template('/quiz/client.html')
|
||||
|
||||
@quiz.route('/result/')
|
||||
def _result():
|
||||
id = session.get('id')
|
||||
entry = Entry.query.filter_by(id=id).first()
|
||||
try: entry = Entry.query.filter_by(id=id).first()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not entry: return abort(404)
|
||||
session.pop('id',None)
|
||||
score = round(100*entry.result['score']/entry.result['max'])
|
||||
|
@ -18,7 +18,7 @@
|
||||
<link rel="shortcut icon" href="{{ url_for('views.static', filename='favicon.ico') }}">
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
||||
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
||||
/>
|
||||
{% block style %}
|
||||
{% endblock %}
|
||||
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
|
||||
<title>{% block title %} SKA Referee Test {% endblock %}</title>
|
||||
{% include "components/og-meta.html" %}
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
@ -1,3 +1,3 @@
|
||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
||||
<p>OpenDyslexic 3 is an open source typeface created by Abbie Gonzalez, licensed under a <a href="https://scripts.sil.org/OFL">SIL-OFL</a>. More information about OpenDyslexic is available <a href="https://opendyslexic.org/">on the project web site</a>.</p>
|
@ -1,6 +1,6 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark" id="primary-nav">
|
||||
<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="d-flex justify-content align-middle">
|
||||
<div class="container d-flex justify-content-center">
|
||||
|
@ -3,9 +3,19 @@
|
||||
{% block content %}
|
||||
<h1>Privacy Policy</h1>
|
||||
|
||||
This web app stores data using cookies. The web site only stores the minimum information it needs to function.
|
||||
<h5>Site Administrators</h5>
|
||||
<ul>
|
||||
<li>
|
||||
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>
|
||||
<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>
|
||||
@ -13,14 +23,14 @@
|
||||
|
||||
<h5>Test Candidates</h5>
|
||||
<ul>
|
||||
<li>The web site will not be trackin your log in, and all information about your test attempt will be stored on your device until you submit it to the server.</li>
|
||||
<li>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>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>
|
||||
|
||||
<h5>Requests to Delete Data</h5>
|
||||
<ul>
|
||||
<li>You can request to have any of your data that is held here deleted by emailing <a href="mailto:refereeing@scotlandkorfball.co.uk">refereeing@scotlandkorfball.co.uk</a>.</li>
|
||||
<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>
|
||||
{% endblock %}
|
@ -1,8 +1,7 @@
|
||||
from .data import load
|
||||
from ..models import User
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import abort, redirect
|
||||
from flask.helpers import flash, url_for
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
from functools import wraps
|
||||
@ -10,9 +9,13 @@ from functools import wraps
|
||||
def require_account_creation(function):
|
||||
@wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
if User.query.count() == 0:
|
||||
flash('Please register a user account.', 'alert')
|
||||
return redirect(url_for('admin._register'))
|
||||
try:
|
||||
if User.query.count() == 0:
|
||||
flash('Please register a user account.', 'alert')
|
||||
return redirect(url_for('admin._register'))
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when checking for existing accounts: {exception}')
|
||||
return abort(500)
|
||||
return function(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
@ -1,12 +1,14 @@
|
||||
from ..models import Dataset
|
||||
from ..models import Dataset, Test
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import current_app as app
|
||||
from flask import flash, redirect
|
||||
from flask.helpers import url_for
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from random import shuffle
|
||||
from statistics import mean, median, stdev
|
||||
from typing import Union
|
||||
from functools import wraps
|
||||
|
||||
def load(filename:str):
|
||||
@ -76,9 +78,91 @@ def get_tag_list(dataset:list):
|
||||
def check_dataset_exists(function):
|
||||
@wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
datasets = Dataset.query.all()
|
||||
try: datasets = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when checking existing datasets: {exception}')
|
||||
return abort(500)
|
||||
if not datasets:
|
||||
flash('There are no available question datasets. Please upload a question dataset first, or use the question editor to create a new dataset.', 'error')
|
||||
return redirect(url_for('admin._questions'))
|
||||
return function(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def check_test_exists(function):
|
||||
@wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
try: tests = Test.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when checking existing datasets: {exception}')
|
||||
return abort(500)
|
||||
if not tests:
|
||||
flash('There are no exams configured. Please create an exam first.', 'error')
|
||||
return redirect(url_for('admin._tests'))
|
||||
return function(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
def analyse(subject:Union[Dataset,Test]) -> dict:
|
||||
output = {
|
||||
'answers': {},
|
||||
'entries': 0,
|
||||
'grades': {
|
||||
'merit': 0,
|
||||
'pass': 0,
|
||||
'fail': 0
|
||||
},
|
||||
'scores': {
|
||||
'mean': 0,
|
||||
'median': 0,
|
||||
'stdev': 0
|
||||
}
|
||||
}
|
||||
scores_raw = []
|
||||
if isinstance(subject, Test):
|
||||
for entry in subject.entries:
|
||||
if entry.answers:
|
||||
for question, answer in entry.answers.items():
|
||||
if int(question) not in output['answers']: output['answers'][int(question)] = {}
|
||||
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
|
||||
output['answers'][int(question)][int(answer)] += 1
|
||||
if entry.result:
|
||||
output['entries'] += 1
|
||||
output['grades'][entry.result['grade']] += 1
|
||||
scores_raw.append(int(entry.result['score']))
|
||||
else:
|
||||
for test in subject.tests:
|
||||
for entry in test.entries:
|
||||
if entry.answers:
|
||||
for question, answer in entry.answers.items():
|
||||
if int(question) not in output['answers']: output['answers'][int(question)] = {}
|
||||
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
|
||||
output['answers'][int(question)][int(answer)] += 1
|
||||
if entry.result:
|
||||
output['entries'] += 1
|
||||
output['grades'][entry.result['grade']] += 1
|
||||
scores_raw.append(entry.result['score'])
|
||||
output['scores']['mean'] = mean(scores_raw)
|
||||
output['scores']['median'] = median(scores_raw)
|
||||
output['scores']['stdev'] = stdev(scores_raw, output['scores']['mean']) if len(scores_raw) > 1 else None
|
||||
return output
|
||||
|
||||
def parse_questions(dataset:list):
|
||||
output = []
|
||||
for block in dataset:
|
||||
if block['type'] == 'question':
|
||||
question = {
|
||||
'q_no': block['q_no'],
|
||||
'tags': block['tags'],
|
||||
'correct': block['correct']
|
||||
}
|
||||
question['options'] = [*enumerate(block['options'])]
|
||||
output.append(question)
|
||||
elif block['type'] == 'block':
|
||||
for _question in block['questions']:
|
||||
question = {
|
||||
'q_no': _question['q_no'],
|
||||
'tags': _question['tags'],
|
||||
'correct': _question['correct']
|
||||
}
|
||||
question['options'] = [*enumerate(_question['options'])]
|
||||
output.append(question)
|
||||
return output
|
@ -1,30 +1,8 @@
|
||||
|
||||
from ..extensions import db
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import jsonify
|
||||
from wtforms.validators import ValidationError
|
||||
|
||||
import json
|
||||
from sqlalchemy.ext import mutable
|
||||
|
||||
class JsonEncodedDict(db.TypeDecorator):
|
||||
"""Enables JSON storage by encoding and decoding on the fly."""
|
||||
impl = db.Text
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return '{}'
|
||||
else:
|
||||
return json.dumps(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return {}
|
||||
else:
|
||||
return json.loads(value)
|
||||
|
||||
mutable.MutableDict.associate_with(JsonEncodedDict)
|
||||
|
||||
def value(min:int=0, max:int=None):
|
||||
if not max:
|
||||
message = f'Value must be greater than {min}.'
|
||||
@ -47,7 +25,10 @@ def get_time_options():
|
||||
|
||||
def get_dataset_choices():
|
||||
from ..models import Dataset
|
||||
datasets = Dataset.query.all()
|
||||
try: datasets = Dataset.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when fetching dataset lists: {exception}')
|
||||
return []
|
||||
dataset_choices = []
|
||||
for dataset in datasets:
|
||||
label = dataset.get_name()
|
||||
|
@ -1,17 +1,19 @@
|
||||
from .data import randomise_list
|
||||
from ..models import Entry
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import redirect, request, session
|
||||
from flask.helpers import url_for
|
||||
from flask import request, session
|
||||
from flask.helpers import abort, redirect, url_for
|
||||
|
||||
from functools import wraps
|
||||
|
||||
def parse_test_code(code):
|
||||
return code.replace('—', '').lower()
|
||||
|
||||
def generate_questions(dataset:list):
|
||||
def generate_questions(dataset:list, randomise:bool=True):
|
||||
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':
|
||||
question = {
|
||||
'type': 'question',
|
||||
@ -19,11 +21,12 @@ def generate_questions(dataset:list):
|
||||
'question_header': '',
|
||||
'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'])]
|
||||
output.append(question)
|
||||
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 = {
|
||||
'type': 'block',
|
||||
'q_no': _question['q_no'],
|
||||
@ -32,7 +35,7 @@ def generate_questions(dataset:list):
|
||||
'block_q_no': key,
|
||||
'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'])]
|
||||
output.append(question)
|
||||
return output
|
||||
@ -129,8 +132,11 @@ def redirect_if_started(function):
|
||||
@wraps(function)
|
||||
def wrapper(*args, **kwargs):
|
||||
id = session.get('id')
|
||||
if request.method == 'GET' and id and Entry.query.filter_by(id=id).first():
|
||||
return redirect(url_for('quiz._quiz'))
|
||||
try:
|
||||
if request.method == 'GET' and id and Entry.query.filter_by(id=id).first(): return redirect(url_for('quiz._quiz'))
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when checking if test has been started: {exception}')
|
||||
return abort(500)
|
||||
return function(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
@ -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
|
||||
$(window).on('load', function() {
|
||||
$.ajax({
|
||||
|
@ -1,2 +1,2 @@
|
||||
<p>This web app was developed by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek’s personal GIT repository</a> under an MIT License.</p>
|
||||
<p>All questions in the test are © The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>
|
@ -1,6 +1,6 @@
|
||||
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest (Beta) | Admin</a>
|
||||
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
@ -20,8 +20,28 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated %}
|
||||
<li class="nav-item" id="nav-results">
|
||||
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a>
|
||||
<li 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
|
||||
@ -36,7 +56,7 @@
|
||||
</a>
|
||||
<ul
|
||||
class="dropdown-menu"
|
||||
aria-labelledby="dropdown-settings"
|
||||
aria-labelledby="dropdown-tests"
|
||||
>
|
||||
<li>
|
||||
<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">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
id="dropdown-account"
|
||||
id="dropdown-settings"
|
||||
role="button"
|
||||
href="{{ url_for('admin._settings') }}"
|
||||
data-bs-toggle="dropdown"
|
||||
|
@ -100,7 +100,18 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-success dataset-analyse {% if not dataset.entries %} disabled {% endif %}"
|
||||
data-id="{{dataset.id}}"
|
||||
title="Analyse Answers"
|
||||
data-action="analyse"
|
||||
>
|
||||
<i class="bi bi-search button-icon"></i>
|
||||
Analyse Answers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -2,9 +2,10 @@ from ..forms.admin import EditDataset
|
||||
from ..models import Dataset, User
|
||||
from ..tools.forms import get_dataset_choices, send_errors_to_client
|
||||
from ..tools.data import check_dataset_exists
|
||||
from ..tools.logs import write
|
||||
|
||||
from flask import Blueprint, flash, jsonify, redirect, render_template, request
|
||||
from flask.helpers import url_for
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from flask.helpers import abort, flash, redirect, url_for
|
||||
from flask_login import login_required
|
||||
|
||||
view = Blueprint(
|
||||
@ -32,10 +33,14 @@ def _view():
|
||||
@login_required
|
||||
@check_dataset_exists
|
||||
def _view_console(id:str=None):
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
datasets = Dataset.query.count()
|
||||
users = User.query.all()
|
||||
try:
|
||||
dataset = Dataset.query.filter_by(id=id).first()
|
||||
datasets = Dataset.query.count()
|
||||
users = User.query.all()
|
||||
except Exception as exception:
|
||||
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
|
||||
return abort(500)
|
||||
if not dataset:
|
||||
flash('Invalid dataset ID.', 'error')
|
||||
return redirect(url_for('admin._questions'))
|
||||
return render_template('/view/console.html', dataset=dataset, datasets=datasets, users=users)
|
||||
return render_template('/view/console.html', dataset=dataset)
|
@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
from main import app
|
||||
from app.extensions import db
|
||||
from app.models import *
|
||||
from app.tools.data import save
|
||||
from app.tools.logs import write
|
||||
from sqlalchemy_utils import create_database, database_exists
|
||||
@ -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/system.log'): write('system.log', 'Log file created.')
|
||||
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
|
||||
if not database_exists(database_uri):
|
||||
create_database(database_uri)
|
||||
write('system.log', 'No database found. Creating a new database.')
|
||||
from app.models import *
|
||||
db.create_all()
|
||||
write('system.log', 'Creating database schema.')
|
||||
if not path.isfile(f'./{data}/.encryption.key'):
|
||||
write('system.log', 'No encryption key found. Generating new encryption key.')
|
||||
with open(f'./{data}/.encryption.key', 'wb') as key_file:
|
||||
key = Fernet.generate_key()
|
||||
key_file.write(key)
|
||||
|
||||
"""Create File for SQLite Database"""
|
||||
if database_uri[0:6].lower() == 'sqlite':
|
||||
if not database_exists(database_uri):
|
||||
create_database(database_uri)
|
||||
write('system.log', 'No SQLite file found. Creating a new database.')
|
@ -1,27 +1,33 @@
|
||||
blinker==1.5
|
||||
cffi==1.15.1
|
||||
click==8.1.3
|
||||
cryptography==37.0.4
|
||||
dnspython==2.2.1
|
||||
cryptography==39.0.2
|
||||
dnspython==2.3.0
|
||||
dominate==2.7.0
|
||||
email-validator==1.2.1
|
||||
Flask==2.2.2
|
||||
email-validator==1.3.1
|
||||
Flask==2.2.3
|
||||
Flask-Bootstrap==3.3.7.1
|
||||
Flask-Login==0.6.2
|
||||
Flask-Mail==0.9.1
|
||||
Flask-SQLAlchemy==2.5.1
|
||||
Flask-WTF==1.0.1
|
||||
greenlet==1.1.2
|
||||
Flask-SQLAlchemy==3.0.3
|
||||
Flask-WTF==1.1.1
|
||||
greenlet==2.0.2
|
||||
gunicorn==20.1.0
|
||||
idna==3.3
|
||||
idna==3.4
|
||||
itsdangerous==2.1.2
|
||||
Jinja2==3.1.2
|
||||
MarkupSafe==2.1.1
|
||||
MarkupSafe==2.1.2
|
||||
pip==23.0.1
|
||||
pycparser==2.21
|
||||
python-dotenv==0.20.0
|
||||
PyMySQL==1.0.2
|
||||
python-dotenv==1.0.0
|
||||
setuptools==67.4.0
|
||||
six==1.16.0
|
||||
SQLAlchemy==1.4.40
|
||||
SQLAlchemy-Utils==0.38.3
|
||||
SQLAlchemy==2.0.4
|
||||
sqlalchemy-json==0.5.0
|
||||
SQLAlchemy-Utils==0.40.0
|
||||
typing_extensions==4.5.0
|
||||
visitor==0.1.3
|
||||
Werkzeug==2.2.2
|
||||
Werkzeug==2.2.3
|
||||
wheel==0.38.4
|
||||
WTForms==3.0.1
|
||||
|
@ -2,16 +2,12 @@
|
||||
from main import app
|
||||
from app.models import User
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
import sys
|
||||
from getpass import getpass
|
||||
|
||||
with app.app_context():
|
||||
try:
|
||||
users = User.query.all()
|
||||
except SQLAlchemyError as exception:
|
||||
sys.exit('Database error:', exception)
|
||||
try: users = User.query.all()
|
||||
except Exception as exception: sys.exit('Database error:', exception)
|
||||
print('')
|
||||
print('This interface will allow you to override the password for an administrator account.')
|
||||
print('To exit this interface, press Ctrl + C.')
|
||||
|
Reference in New Issue
Block a user