From 90bc30757a106b538d2c779b1304d20af64c000b Mon Sep 17 00:00:00 2001 From: Vivek Santayana Date: Mon, 20 Jun 2022 11:26:44 +0100 Subject: [PATCH 1/5] Added local server for development --- ref-test/app/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ref-test/app/config.py b/ref-test/app/config.py index e62ead5..79b607d 100644 --- a/ref-test/app/config.py +++ b/ref-test/app/config.py @@ -34,6 +34,7 @@ class Production(Config): class Development(Config): APP_HOST = '127.0.0.1' DEBUG = True + SERVER_NAME = '127.0.0.1:5000' SESSION_COOKIE_SECURE = False MAIL_SERVER = 'localhost' MAIL_DEBUG = True From 49a7fb10070e1f77dfe65a8310db839b6209b2c6 Mon Sep 17 00:00:00 2001 From: Vivek Santayana Date: Mon, 20 Jun 2022 11:27:05 +0100 Subject: [PATCH 2/5] More elegant error handling --- ref-test/app/admin/views.py | 30 ++++++++++-------------------- ref-test/app/quiz/views.py | 4 ++-- ref-test/app/tools/forms.py | 7 ++++++- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/ref-test/app/admin/views.py b/ref-test/app/admin/views.py index cfea08f..36799b8 100644 --- a/ref-test/app/admin/views.py +++ b/ref-test/app/admin/views.py @@ -1,7 +1,7 @@ from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData from ..models import Dataset, Entry, Test, User from ..tools.auth import disable_if_logged_in, require_account_creation -from ..tools.forms import get_dataset_choices, get_time_options +from ..tools.forms import get_dataset_choices, get_time_options, send_errors_to_client from ..tools.data import check_is_json, validate_json 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({'error': f'The password you entered is incorrect.'}), 401 return jsonify({'error': f'The username you entered does not exist.'}), 401 - errors = [*form.username.errors, *form.password.errors] - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) if 'remembered_username' in session: form.username.data = session.pop('remembered_username') next = request.args.get('next') return render_template('/admin/auth/login.html', form=form, next=next) @@ -90,8 +89,7 @@ def _register(): return jsonify({'success': message}), 200 flash(message=message, category='error') return jsonify({'error': message}), 401 - errors = [*form.username.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) return render_template('admin/auth/register.html', form=form) @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.get_email() == request.form.get('email'): return jsonify({'error': 'The email address does not match the user account.'}), 400 return user.reset_password() - errors = [*form.username.errors, *form.email.errors] - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) token = request.args.get('token') if token: @@ -134,8 +131,7 @@ def _update_password(): session['remembered_username'] = user.get_username() flash('Your password has been reset.', 'success') return jsonify({'success':'Your password has been reset'}), 200 - errors = [*form.password.errors, *form.password_reenter.errors] - return jsonify({ 'error': errors}), 401 + return send_errors_to_client(form=form) @admin.route('/settings/users/', methods=['GET', 'POST']) @login_required @@ -152,8 +148,7 @@ def _users(): success, message = new_user.register(notify=request.form.get('notify'), password=password) if success: return jsonify({'success': message}), 200 return jsonify({'error': message}), 401 - errors = [*form.username.errors, *form.email.errors, *form.password.errors] - return jsonify({ 'error': errors}), 401 + return send_errors_to_client(form=form) return render_template('/admin/settings/users.html', form = form, users = users) @admin.route('/settings/users/delete/', methods=['GET', 'POST']) @@ -170,8 +165,7 @@ def _delete_user(id:str): success, message = user.delete(notify=request.form.get('notify')) if success: return jsonify({'success': message}), 200 return jsonify({'error': message}), 400 - errors = form.password.errors - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) if id == current_user.id: flash('Cannot delete your own user account.', 'error') @@ -199,8 +193,7 @@ def _update_user(id:str): flash(message, 'success') return jsonify({'success': message}), 200 return jsonify({'error': message}), 400 - errors = [*form.confirm_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) if not user: flash('User not found.', 'error') return redirect(url_for('admin._users')) @@ -222,8 +215,7 @@ def _questions(): ) if success: return jsonify({'success': message}), 200 return jsonify({'error': message}), 400 - errors = form.data_file.errors - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) data = Dataset.query.all() return render_template('/admin/settings/questions.html', form=form, data=data) @@ -299,9 +291,7 @@ def _create_test(): flash(message=message, category='success') return jsonify({'success': message}), 200 return jsonify({'error': message}), 400 - else: - errors = [*form.start_date.errors, *form.expiry_date.errors, *form.time_limit.errors] - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) @admin.route('/tests/edit/', methods=['POST']) @login_required diff --git a/ref-test/app/quiz/views.py b/ref-test/app/quiz/views.py index 8c8000c..c6c437b 100644 --- a/ref-test/app/quiz/views.py +++ b/ref-test/app/quiz/views.py @@ -1,5 +1,6 @@ from ..forms.quiz import StartQuiz from ..models import Entry, Test +from ..tools.forms import send_errors_to_client from ..tools.test import redirect_if_started from flask import abort, Blueprint, jsonify, redirect, render_template, request, session @@ -52,8 +53,7 @@ def _start(): 'id': entry.id }), 200 return jsonify({'error': 'There was an error processing the user test and/or user codes.'}), 400 - errors = [*form.test_code.errors, *form.user_code.errors, *form.first_name.errors, *form.surname.errors, *form.email.errors, *form.club.errors] - return jsonify({ 'error': errors}), 400 + return send_errors_to_client(form=form) return render_template('/quiz/start_quiz.html', form = form) @quiz.route('/quiz/') diff --git a/ref-test/app/tools/forms.py b/ref-test/app/tools/forms.py index cef280d..eeea160 100644 --- a/ref-test/app/tools/forms.py +++ b/ref-test/app/tools/forms.py @@ -1,6 +1,7 @@ from ..extensions import db +from flask import jsonify from wtforms.validators import ValidationError import json @@ -53,4 +54,8 @@ def get_dataset_choices(): label = f'{label} (Default)' if dataset.default else label choice = (dataset.id, label) dataset_choices.append(choice) - return dataset_choices \ No newline at end of file + return dataset_choices + +def send_errors_to_client(form): + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 \ No newline at end of file From d6836915bbadfbab77d4c4f4584f433a2e747c01 Mon Sep 17 00:00:00 2001 From: Vivek Santayana Date: Mon, 20 Jun 2022 12:09:31 +0100 Subject: [PATCH 3/5] Prevent edit user from duplicating email address --- ref-test/app/models/user.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ref-test/app/models/user.py b/ref-test/app/models/user.py index a1cec1d..cd945c2 100644 --- a/ref-test/app/models/user.py +++ b/ref-test/app/models/user.py @@ -189,7 +189,10 @@ class User(UserMixin, db.Model): if not password and not email: return False, 'There were no changes requested.' if password: self.set_password(password) old_email = self.get_email() - if email: self.set_email(email) + 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() write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.') if notify: From 26227a66c51a46dc1cb7f3b61beeb6c12e059fa5 Mon Sep 17 00:00:00 2001 From: Vivek Santayana Date: Mon, 20 Jun 2022 12:10:37 +0100 Subject: [PATCH 4/5] App Factory pattern --- ref-test/app/__init__.py | 60 +++++++++++++++++++++++++++ ref-test/app/admin/views.py | 2 +- ref-test/app/install.py | 33 +++++++++++++++ ref-test/main.py | 83 +------------------------------------ 4 files changed, 95 insertions(+), 83 deletions(-) create mode 100644 ref-test/app/install.py diff --git a/ref-test/app/__init__.py b/ref-test/app/__init__.py index e69de29..09bb477 100644 --- a/ref-test/app/__init__.py +++ b/ref-test/app/__init__.py @@ -0,0 +1,60 @@ +from .config import Development as Config +from .install import install_app +from .models import Entry, Dataset, Test, User +from .extensions import bootstrap, csrf, db, login_manager, mail +from .tools.data import save +from .tools.logs import write + +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 cryptography.fernet import Fernet +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'Cookie Consent: 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 privacy policy.', '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 \ No newline at end of file diff --git a/ref-test/app/admin/views.py b/ref-test/app/admin/views.py index 36799b8..c2adfcb 100644 --- a/ref-test/app/admin/views.py +++ b/ref-test/app/admin/views.py @@ -375,7 +375,7 @@ def _view_entry(id:str=None): @admin.route('/certificate/',methods=['POST']) @login_required def _generate_certificate(): - from main import db + from ..extensions import db id = request.get_json()['id'] entry = Entry.query.filter_by(id=id).first() if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404 diff --git a/ref-test/app/install.py b/ref-test/app/install.py new file mode 100644 index 0000000..3ab1fa4 --- /dev/null +++ b/ref-test/app/install.py @@ -0,0 +1,33 @@ +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.') + 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) \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index e66e134..317219e 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -1,84 +1,3 @@ -from app.config import Development as Config -from app.models import Entry, Dataset, Test, User -from app.extensions import bootstrap, csrf, db, login_manager, mail -from app.tools.data import save -from app.tools.logs import write - -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 -from pathlib import 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/', '/root/', '/quiz/static', '/cookies/' ] ]): - return - flash(f'Cookie Consent: 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 privacy policy.', '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) - - data = Path(app.config.get('DATA')) - 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 - +from app import create_app app = create_app() - if __name__ == '__main__': app.run() \ No newline at end of file From 6a09559b7055ed34e815ba682e8fa7455c6dc527 Mon Sep 17 00:00:00 2001 From: Vivek Santayana Date: Mon, 20 Jun 2022 12:10:52 +0100 Subject: [PATCH 5/5] Database URI absolute path fix --- ref-test/app/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/app/config.py b/ref-test/app/config.py index 79b607d..48aa4f1 100644 --- a/ref-test/app/config.py +++ b/ref-test/app/config.py @@ -13,7 +13,7 @@ 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(DATA)}/database.db' + SQLALCHEMY_DATABASE_URI = f'sqlite:///{Path(os.path.abspath(f"{DATA}/database.db"))}' SQLALCHEMY_TRACK_MODIFICATIONS = False MAIL_SERVER = os.getenv('MAIL_SERVER')