18 Commits

Author SHA1 Message Date
1ef34465c2 Debug install script 2022-06-20 12:53:40 +01:00
d9962f18ed Production ready v.0.2.1 2022-06-20 12:28:00 +01:00
d8044a7c76 Make config production ready 2022-06-20 12:27:32 +01:00
2663d5e3b7 Tidied up unused imports 2022-06-20 12:15:28 +01:00
6a09559b70 Database URI absolute path fix 2022-06-20 12:10:52 +01:00
26227a66c5 App Factory pattern 2022-06-20 12:10:37 +01:00
d6836915bb Prevent edit user from duplicating email address 2022-06-20 12:09:31 +01:00
49a7fb1007 More elegant error handling 2022-06-20 11:27:05 +01:00
90bc30757a Added local server for development 2022-06-20 11:26:44 +01:00
12207d1159 Changed modules to extensions 2022-06-19 13:22:24 +01:00
ac02f4dee1 Changed structure of referencing data 2022-06-19 13:22:05 +01:00
76fa1e1dd9 Removed todo tags 2022-06-19 11:17:21 +01:00
6d5f74bd62 Tidied up code 2022-06-19 11:17:00 +01:00
2e00d503c8 Added detailed data validation 2022-06-19 11:13:47 +01:00
516c2cdf81 Buxfix: static folders bypass cookie consent 2022-06-18 09:26:05 +01:00
8f9b78ac32 Merge branch 'editor' 2022-06-18 02:18:45 +01:00
17b985d238 Bugfix: 404 errors with request.endpoint
Fixed static folder 404 errors
2022-06-18 02:18:07 +01:00
69a0791a6d Bug fixes to main branch 2022-06-18 02:11:29 +01:00
25 changed files with 287 additions and 162 deletions

View File

@ -2,7 +2,12 @@
# Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71 # Source https://pentacent.medium.com/nginx-and-lets-encrypt-with-docker-in-less-than-5-minutes-b4b8a60d3a71
if ! [ -x "$(command -v docker compose)" ]; then if ! [ -x "$(command -v docker)" ]; then
echo 'Error: docker is not installed.' >&2
exit 1
fi
if ! [ -x "$(command -v compose)" ]; then
echo 'Error: docker compose is not installed.' >&2 echo 'Error: docker compose is not installed.' >&2
exit 1 exit 1
fi fi

View File

@ -0,0 +1,57 @@
from .config import Production as Config
from .install import install_app
from .models import User
from .extensions import bootstrap, csrf, db, login_manager, mail
from flask import flash, Flask, render_template, request
from flask.helpers import url_for
from flask.json import jsonify
from flask_wtf.csrf import CSRFError
from werkzeug.middleware.proxy_fix import ProxyFix
from datetime import datetime
def create_app():
app = Flask(__name__)
app.config.from_object(Config())
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto= 1, x_host= 1)
bootstrap.init_app(app)
csrf.init_app(app)
db.init_app(app)
login_manager.init_app(app)
mail.init_app(app)
login_manager.login_view = 'admin._login'
@login_manager.user_loader
def _load_user(id):
return User.query.filter_by(id=id).first()
@app.before_request
def _check_cookie_consent():
if request.cookies.get('cookie_consent'):
return
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/root/', '/quiz/static', '/cookies/' ] ]):
return
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
@app.errorhandler(404)
def _404_handler(error): return render_template('404.html')
@app.errorhandler(CSRFError)
def _csrf_handler(): return jsonify({'error':'Could not validate a secure connection.'}), 403
@app.context_processor
def _now(): return {'now': datetime.now()}
from .admin.views import admin
from .api.views import api
from .quiz.views import quiz
from .views import views
app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(api, url_prefix='/api')
app.register_blueprint(views)
app.register_blueprint(quiz)
install_app(app)
return app

View File

@ -77,7 +77,7 @@ $('.test-action').click(function(event) {
$.ajax({ $.ajax({
url: `/admin/tests/edit/`, url: `/admin/tests/edit/`,
type: 'POST', type: 'POST',
data: JSON.stringify({'id': id, 'action': action}), // TODO Change how CRUD operations work data: JSON.stringify({'id': id, 'action': action}),
contentType: 'application/json', contentType: 'application/json',
success: function(response) { success: function(response) {
window.location.href = '/admin/tests/'; window.location.href = '/admin/tests/';

View File

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

View File

@ -1,7 +1,7 @@
from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
from ..models import Dataset, Entry, Test, User from ..models import Dataset, Entry, Test, User
from ..tools.auth import disable_if_logged_in, require_account_creation from ..tools.auth import disable_if_logged_in, require_account_creation
from ..tools.forms import get_dataset_choices, get_time_options from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client
from ..tools.data import check_is_json, validate_json from ..tools.data import check_is_json, validate_json
from ..tools.test import answer_options, get_correct_answers from ..tools.test import answer_options, get_correct_answers
@ -61,8 +61,7 @@ def _login():
return jsonify({'success': f'Successfully logged in.'}), 200 return jsonify({'success': f'Successfully logged in.'}), 200
return jsonify({'error': f'The password you entered is incorrect.'}), 401 return jsonify({'error': f'The password you entered is incorrect.'}), 401
return jsonify({'error': f'The username you entered does not exist.'}), 401 return jsonify({'error': f'The username you entered does not exist.'}), 401
errors = [*form.username.errors, *form.password.errors] return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 400
if 'remembered_username' in session: form.username.data = session.pop('remembered_username') if 'remembered_username' in session: form.username.data = session.pop('remembered_username')
next = request.args.get('next') next = request.args.get('next')
return render_template('/admin/auth/login.html', form=form, next=next) return render_template('/admin/auth/login.html', form=form, next=next)
@ -90,8 +89,7 @@ def _register():
return jsonify({'success': message}), 200 return jsonify({'success': message}), 200
flash(message=message, category='error') flash(message=message, category='error')
return jsonify({'error': message}), 401 return jsonify({'error': message}), 401
errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 400
return render_template('admin/auth/register.html', form=form) return render_template('admin/auth/register.html', form=form)
@admin.route('/reset/', methods=['GET','POST']) @admin.route('/reset/', methods=['GET','POST'])
@ -108,8 +106,7 @@ def _reset():
if not user: return jsonify({'error': 'The user account does not exist.'}), 400 if not user: return jsonify({'error': 'The user account does not exist.'}), 400
if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400 if not user.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400
return user.reset_password() return user.reset_password()
errors = [*form.username.errors, *form.email.errors] return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 400
token = request.args.get('token') token = request.args.get('token')
if token: if token:
@ -134,8 +131,7 @@ def _update_password():
session['remembered_username'] = user.get_username() session['remembered_username'] = user.get_username()
flash('Your password has been reset.', 'success') flash('Your password has been reset.', 'success')
return jsonify({'success':'Your password has been reset'}), 200 return jsonify({'success':'Your password has been reset'}), 200
errors = [*form.password.errors, *form.password_reenter.errors] return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 401
@admin.route('/settings/users/', methods=['GET', 'POST']) @admin.route('/settings/users/', methods=['GET', 'POST'])
@login_required @login_required
@ -152,8 +148,7 @@ def _users():
success, message = new_user.register(notify=request.form.get('notify'), password=password) success, message = new_user.register(notify=request.form.get('notify'), password=password)
if success: return jsonify({'success': message}), 200 if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 401 return jsonify({'error': message}), 401
errors = [*form.username.errors, *form.email.errors, *form.password.errors] return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 401
return render_template('/admin/settings/users.html', form = form, users = users) return render_template('/admin/settings/users.html', form = form, users = users)
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST']) @admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
@ -170,8 +165,7 @@ def _delete_user(id:str):
success, message = user.delete(notify=request.form.get('notify')) success, message = user.delete(notify=request.form.get('notify'))
if success: return jsonify({'success': message}), 200 if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
errors = form.password.errors return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 400
if id == current_user.id: if id == current_user.id:
flash('Cannot delete your own user account.', 'error') flash('Cannot delete your own user account.', 'error')
@ -199,8 +193,7 @@ def _update_user(id:str):
flash(message, 'success') flash(message, 'success')
return jsonify({'success': message}), 200 return jsonify({'success': message}), 200
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
errors = [*form.confirm_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 400
if not user: if not user:
flash('User not found.', 'error') flash('User not found.', 'error')
return redirect(url_for('admin._users')) return redirect(url_for('admin._users'))
@ -214,7 +207,7 @@ def _questions():
if form.validate_on_submit(): if form.validate_on_submit():
upload = form.data_file.data upload = form.data_file.data
if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400 if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400
if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400 # TODO Perhaps make a more complex validation script if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400
new_dataset = Dataset() new_dataset = Dataset()
success, message = new_dataset.create( success, message = new_dataset.create(
upload = upload, upload = upload,
@ -222,8 +215,7 @@ def _questions():
) )
if success: return jsonify({'success': message}), 200 if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
errors = form.data_file.errors return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 400
data = Dataset.query.all() data = Dataset.query.all()
return render_template('/admin/settings/questions.html', form=form, data=data) return render_template('/admin/settings/questions.html', form=form, data=data)
@ -299,9 +291,7 @@ def _create_test():
flash(message=message, category='success') flash(message=message, category='success')
return jsonify({'success': message}), 200 return jsonify({'success': message}), 200
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
else: return send_errors_to_client(form=form)
errors = [*form.start_date.errors, *form.expiry_date.errors, *form.time_limit.errors]
return jsonify({ 'error': errors}), 400
@admin.route('/tests/edit/', methods=['POST']) @admin.route('/tests/edit/', methods=['POST'])
@login_required @login_required
@ -385,7 +375,7 @@ def _view_entry(id:str=None):
@admin.route('/certificate/',methods=['POST']) @admin.route('/certificate/',methods=['POST'])
@login_required @login_required
def _generate_certificate(): def _generate_certificate():
from main import db from ..extensions import db
id = request.get_json()['id'] id = request.get_json()['id']
entry = Entry.query.filter_by(id=id).first() entry = Entry.query.filter_by(id=id).first()
if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404 if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404

View File

@ -1,9 +1,7 @@
import os import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv
if not os.getenv('DATA'): load_dotenv('../.env')
from dotenv import load_dotenv
load_dotenv('../.env')
class Config(object): class Config(object):
APP_HOST = '0.0.0.0' APP_HOST = '0.0.0.0'
@ -13,7 +11,7 @@ class Config(object):
SECRET_KEY = os.getenv('SECRET_KEY') SECRET_KEY = os.getenv('SECRET_KEY')
SERVER_NAME = os.getenv('SERVER_NAME') SERVER_NAME = os.getenv('SERVER_NAME')
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(DATA)}/database.db' SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/database.db"))}'
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
MAIL_SERVER = os.getenv('MAIL_SERVER') MAIL_SERVER = os.getenv('MAIL_SERVER')
@ -28,18 +26,19 @@ class Config(object):
MAIL_SUPPRESS_SEND = False MAIL_SUPPRESS_SEND = False
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS')) MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
class ProductionConfig(Config): class Production(Config):
pass pass
class DevelopmentConfig(Config): class Development(Config):
APP_HOST = '127.0.0.1' APP_HOST = '127.0.0.1'
DEBUG = True DEBUG = True
SERVER_NAME = '127.0.0.1:5000'
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
MAIL_SERVER = 'localhost' MAIL_SERVER = 'localhost'
MAIL_DEBUG = True MAIL_DEBUG = True
MAIL_SUPPRESS_SEND = False MAIL_SUPPRESS_SEND = False
class TestingConfig(DevelopmentConfig): class Testing(Development):
TESTING = True TESTING = True
SESSION_COOKIE_SECURE = False SESSION_COOKIE_SECURE = False
MAIL_SERVER = os.getenv('MAIL_SERVER') MAIL_SERVER = os.getenv('MAIL_SERVER')

View File

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

34
ref-test/app/install.py Normal file
View File

@ -0,0 +1,34 @@
from .extensions import db
from .tools.data import save
from .tools.logs import write
from sqlalchemy_utils import create_database, database_exists
from cryptography.fernet import Fernet
from os import mkdir, path
from pathlib import Path
def install_app(app):
with app.app_context():
data = Path(app.config.get('DATA'))
database_uri = app.config.get('SQLALCHEMY_DATABASE_URI')
if not path.isdir(f'./{data}'): mkdir(f'./{data}')
if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions')
if not path.isfile(f'./{data}/.gitignore'):
with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*')
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
if not database_exists(database_uri):
create_database(database_uri)
write('system.log', 'No database found. Creating a new database.')
from .models import Entry, Dataset, Test, User
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)

View File

@ -1,14 +1,15 @@
from ..data import data from ..extensions import db
from ..modules import db
from ..tools.logs import write from ..tools.logs import write
from flask import flash from flask import flash
from flask import current_app as app
from flask_login import current_user from flask_login import current_user
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from datetime import datetime from datetime import datetime
from json import dump, loads from json import dump, loads
from os import path, remove from os import path, remove
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
class Dataset(db.Model): class Dataset(db.Model):
@ -48,6 +49,7 @@ class Dataset(db.Model):
return False, message return False, message
write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.') write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.')
filename = secure_filename('.'.join([self.id,'json'])) filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename) file_path = path.join(data, 'questions', filename)
remove(file_path) remove(file_path)
db.session.delete(self) db.session.delete(self)
@ -58,6 +60,7 @@ class Dataset(db.Model):
self.generate_id() self.generate_id()
timestamp = datetime.now() timestamp = datetime.now()
filename = secure_filename('.'.join([self.id,'json'])) filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename) file_path = path.join(data, 'questions', filename)
upload.stream.seek(0) upload.stream.seek(0)
questions = loads(upload.read()) questions = loads(upload.read())
@ -73,11 +76,13 @@ class Dataset(db.Model):
def check_file(self): def check_file(self):
filename = secure_filename('.'.join([self.id,'json'])) filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename) file_path = path.join(data, 'questions', filename)
if not path.isfile(file_path): return False, 'Data file is missing.' if not path.isfile(file_path): return False, 'Data file is missing.'
return True, 'Data file found.' return True, 'Data file found.'
def get_file(self): def get_file(self):
filename = secure_filename('.'.join([self.id,'json'])) filename = secure_filename('.'.join([self.id,'json']))
data = Path(app.config.get('DATA'))
file_path = path.join(data, 'questions', filename) file_path = path.join(data, 'questions', filename)
return file_path return file_path

View File

@ -1,4 +1,4 @@
from ..modules import db, mail from ..extensions import db, mail
from ..tools.forms import JsonEncodedDict from ..tools.forms import JsonEncodedDict
from ..tools.encryption import decrypt, encrypt from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write from ..tools.logs import write

View File

@ -1,4 +1,4 @@
from ..modules import db from ..extensions import db
from ..tools.encryption import decrypt, encrypt from ..tools.encryption import decrypt, encrypt
from ..tools.forms import JsonEncodedDict from ..tools.forms import JsonEncodedDict
from ..tools.logs import write from ..tools.logs import write

View File

@ -1,4 +1,4 @@
from ..modules import db, mail from ..extensions import db, mail
from ..tools.encryption import decrypt, encrypt from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write from ..tools.logs import write
@ -189,7 +189,10 @@ class User(UserMixin, db.Model):
if not password and not email: return False, 'There were no changes requested.' if not password and not email: return False, 'There were no changes requested.'
if password: self.set_password(password) if password: self.set_password(password)
old_email = self.get_email() old_email = self.get_email()
if email: self.set_email(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.'
self.set_email(email)
db.session.commit() db.session.commit()
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.') write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
if notify: if notify:

View File

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

View File

@ -1,5 +1,6 @@
from ..forms.quiz import StartQuiz from ..forms.quiz import StartQuiz
from ..models import Entry, Test from ..models import Entry, Test
from ..tools.forms import send_errors_to_client
from ..tools.test import redirect_if_started from ..tools.test import redirect_if_started
from flask import abort, Blueprint, jsonify, redirect, render_template, request, session from flask import abort, Blueprint, jsonify, redirect, render_template, request, session
@ -52,8 +53,7 @@ def _start():
'id': entry.id 'id': entry.id
}), 200 }), 200
return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400 return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400
errors = [*form.test_code.errors, *form.user_code.errors, *form.first_name.errors, *form.surname.errors, *form.email.errors, *form.club.errors] return send_errors_to_client(form=form)
return jsonify({ 'error': errors}), 400
return render_template('/quiz/start_quiz.html', form = form) return render_template('/quiz/start_quiz.html', form = form)
@quiz.route('/quiz/') @quiz.route('/quiz/')

View File

@ -1,8 +1,82 @@
{% extends "components/base.html" %} <!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
<link
rel="stylesheet"
href="{{ url_for('views.static', filename='css/style.css') }}"
/>
<link rel="shortcut icon" href="{{ url_for('views.static', filename='favicon.ico') }}">
{% block style %}
{% endblock %}
<title>{% block title %} SKA Referee Test Beta {% endblock %}</title>
</head>
<body class="bg-light">
{% block content %} {% block navbar %}
<h1>Page Not Found</h1> {% include "components/navbar.html" %}
<p> {% endblock %}
The page you were looking for does not exist. Try going back and navigating to the desired destination correctly.
</p> <div class="container quiz-container">
{% endblock %} {% block top_alerts %}
{% include "components/server-alerts.html" %}
{% endblock %}
{% block content %}
<h1>Page Not Found</h1>
<p>
The page you were looking for does not exist. Try going back and navigating to the desired destination correctly.
</p>
{% endblock %}
<footer class="container site-footer">
{% include "components/footer.html" %}
</footer>
</div>
<!-- JQuery, Popper, and Bootstrap js dependencies -->
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous">
</script>
<script>
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
</script>
<script
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
crossorigin="anonymous">
</script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous"
></script>
<!-- Custom js -->
<script type="text/javascript">
var csrf_token = "{{ csrf_token() }}";
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
}
}
});
</script>
<script
type="text/javascript"
src="{{ url_for('views.static', filename='js/script.js') }}"
></script>
{% block script %}
{% endblock %}
</body>
</html>

View File

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

View File

@ -1,13 +1,16 @@
from ..data import data as data_dir from flask import current_app as app
import json import json
from pathlib import Path
from random import shuffle from random import shuffle
def load(filename:str): def load(filename:str):
data_dir = Path(app.config.get('DATA'))
with open(f'./{data_dir}/{filename}') as file: with open(f'./{data_dir}/{filename}') as file:
return json.load(file) return json.load(file)
def save(data:dict, filename:str): def save(data:dict, filename:str):
data_dir = Path(app.config.get('DATA'))
with open(f'./{data_dir}/{filename}', 'w') as file: with open(f'./{data_dir}/{filename}', 'w') as file:
json.dump(data, file, indent=4) json.dump(data, file, indent=4)
@ -19,6 +22,39 @@ def validate_json(file):
file.stream.seek(0) file.stream.seek(0)
data = json.loads(file.read()) data = json.loads(file.read())
if not isinstance(data, list): return False if not isinstance(data, list): return False
for block in data:
block_type = block.pop('type', None)
if block_type not in ['block', 'question']: return False
if block_type == 'question':
if not all (key in block for key in ['q_no', 'text', 'options', 'correct', 'q_type', 'tags']): return False
if not isinstance(block['q_no'], int): return False
if not isinstance(block['text'], str): return False
if not isinstance(block['options'], list): return False
for option in block['options']:
if not isinstance(option, str): return False
if not isinstance(block['correct'], int): return False
if not isinstance(block['q_type'], str): return False
if block['q_type'] not in ['Multiple Choice', 'Yes/No', 'List']: return False
if not isinstance(block['tags'], list): return False
for tag in block['tags']:
if not isinstance(tag, str): return False
if block_type == 'block':
if not all (key in block for key in ['question_header', 'questions']): return False
if not isinstance(block['question_header'], str): return False
if not isinstance(block['questions'], list): return False
for question in block['questions']:
if not all (key in question for key in ['q_no', 'text', 'options', 'correct', 'q_type', 'tags']): return False
if not isinstance(question['text'], str): return False
if not isinstance(question['q_no'], int): return False
if not isinstance(question['options'], list): return False
for option in question['options']:
if not isinstance(option, str): return False
if not isinstance(question['correct'], int): return False
if not isinstance(question['q_type'], str): return False
if question['q_type'] not in ['Multiple Choice', 'Yes/No', 'List']: return False
if not isinstance(question['tags'], list): return False
for tag in question['tags']:
if not isinstance(tag, str): return False
return True return True
def randomise_list(list:list): def randomise_list(list:list):

View File

@ -1,7 +1,10 @@
from ..data import data from flask import current_app as app
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from pathlib import Path
def load_key(): def load_key():
data = Path(app.config.get('DATA'))
with open(f'./{data}/.encryption.key', 'rb') as keyfile: return keyfile.read() with open(f'./{data}/.encryption.key', 'rb') as keyfile: return keyfile.read()
def decrypt(input:str): def decrypt(input:str):

View File

@ -1,6 +1,7 @@
from ..modules import db from ..extensions import db
from flask import jsonify
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
import json import json
@ -53,4 +54,8 @@ def get_dataset_choices():
label = f'{label} (Default)' if dataset.default else label label = f'{label} (Default)' if dataset.default else label
choice = (dataset.id, label) choice = (dataset.id, label)
dataset_choices.append(choice) dataset_choices.append(choice)
return dataset_choices return dataset_choices
def send_errors_to_client(form):
errors = [*form.errors]
return jsonify({ 'error': errors}), 400

View File

@ -1,10 +1,14 @@
from ..data import data from flask import current_app as app
from datetime import datetime from datetime import datetime
from pathlib import Path
def read(filename:str): def read(filename:str):
data = Path(app.config.get('DATA'))
with open(f'./{data}/logs/{filename}') as file: with open(f'./{data}/logs/{filename}') as file:
return file.readlines() return file.readlines()
def write(filename:str, message:str): def write(filename:str, message:str):
data = Path(app.config.get('DATA'))
with open(f'./{data}/logs/{filename}', 'a+') as file: with open(f'./{data}/logs/{filename}', 'a+') as file:
file.write(f'{datetime.now().strftime("%Y-%m-%d-%X")}: {message}\n') file.write(f'{datetime.now().strftime("%Y-%m-%d-%X")}: {message}\n')

View File

@ -1,6 +1,5 @@
from .config import Config
from flask import Blueprint, redirect, request, render_template from flask import Blueprint, redirect, request, render_template
from flask import current_app as app
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -24,7 +23,7 @@ def _cookie_consent():
max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None, max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else None,
path = '/', path = '/',
expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else None, expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else None,
domain = f'{Config.SERVER_NAME}', domain = f'{app.config.get("SERVER_NAME")}',
secure = True secure = True
) )
return resp return resp

View File

@ -1 +0,0 @@
from app.config import ProductionConfig as Config

View File

@ -1,87 +1,3 @@
from app.data import data from app import create_app
from app.models import Entry, Dataset, Test, User
from app.modules import bootstrap, csrf, db, login_manager, mail
from app.tools.data import save
from app.tools.logs import write
from config import Config
from flask import flash, Flask, render_template, request
from flask.helpers import url_for
from flask.json import jsonify
from flask_wtf.csrf import CSRFError
from sqlalchemy_utils import database_exists, create_database
from werkzeug.middleware.proxy_fix import ProxyFix
from cryptography.fernet import Fernet
from datetime import datetime
from os import mkdir, path
def create_app():
app = Flask(__name__)
app.config.from_object(Config())
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto= 1, x_host= 1)
bootstrap.init_app(app)
csrf.init_app(app)
db.init_app(app)
login_manager.init_app(app)
mail.init_app(app)
login_manager.login_view = 'admin._login'
@login_manager.user_loader
def _load_user(id):
return User.query.filter_by(id=id).first()
@app.before_request
def _check_cookie_consent():
if request.cookies.get('cookie_consent'):
return
if any([ request.path.startswith(x) for x in [ '/admin/static/', '/static/', '/cookies/' ] ]):
return
flash(f'<strong>Cookie Consent</strong>: This web site only stores minimal, functional cookies. It does not store any tracking information. By using this site, you consent to this use of cookies. For more information, see our <a href="{url_for("views._privacy")}">privacy policy</a>.', 'cookie_alert')
@app.errorhandler(404)
def _404_handler(error):
return render_template('404.html')
@app.errorhandler(CSRFError)
def _csrf_handler():
return jsonify({'error':'Could not validate a secure connection.'}), 403
@app.context_processor
def _now():
return {'now': datetime.now()}
from app.admin.views import admin
from app.api.views import api
from app.quiz.views import quiz
from app.views import views
app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(api, url_prefix='/api')
app.register_blueprint(views)
app.register_blueprint(quiz)
if not path.isdir(f'./{data}'): mkdir(f'./{data}')
if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions')
if not path.isfile(f'./{data}/.gitignore'):
with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*')
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
if not database_exists(Config.SQLALCHEMY_DATABASE_URI):
create_database(Config.SQLALCHEMY_DATABASE_URI)
write('system.log', 'No database found. Creating a new database.')
with app.app_context(): db.create_all()
write('system.log', 'Creating database schema.')
if not path.isfile(f'./{data}/.encryption.key'):
write('system.log', 'No encryption key found. Generating new encryption key.')
with open(f'./{data}/.encryption.key', 'wb') as key_file:
key = Fernet.generate_key()
key_file.write(key)
return app
app = create_app() app = create_app()
if __name__ == '__main__': app.run()
if __name__ == '__main__':
app.run()

View File

@ -1,4 +1,2 @@
from main import app from main import app
if __name__ == '__main__': app.run()
if __name__ == '__main__':
app.run()