From 35b0d30739fac842c06be1f520f2b23b72d1834c Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 001/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 15 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From c7252d0f7b5bb2b9a80e9a6edad59ed7eb9044e4 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 002/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/models/users.py | 4 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/blueprints.py | 18 +++++ ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 17 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 ref-test/common/blueprints.py create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/models/users.py b/ref-test/admin/models/users.py index a983975..5db7156 100644 --- a/ref-test/admin/models/users.py +++ b/ref-test/admin/models/users.py @@ -6,8 +6,8 @@ from werkzeug.utils import redirect from flask_mail import Message import secrets -from security import encrypt, decrypt -from security.database import decrypt_find_one, encrypted_update +from common.security import encrypt, decrypt +from common.security.database import decrypt_find_one, encrypted_update from datetime import datetime, timedelta from main import db, mail diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/blueprints.py b/ref-test/common/blueprints.py new file mode 100644 index 0000000..d15e272 --- /dev/null +++ b/ref-test/common/blueprints.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta +from flask import Blueprint, redirect, request + +cookie_consent = Blueprint( + 'cookie_consent', + __name__ +) +@cookie_consent.route('/') +def _cookies(): + resp = redirect('/') + resp.set_cookie( + key = 'cookie_consent', + value = 'True', + max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', + path = '/', + expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' + ) + return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From 6d5f8bc00c0c1964c2bb50cd64e70cfacb92cc2c Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 003/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 15 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From 059dca4a40b80b1e1399edb8f2e949a5dee9cddd Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 004/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/models/users.py | 4 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/blueprints.py | 18 +++++ ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 17 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 ref-test/common/blueprints.py create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/models/users.py b/ref-test/admin/models/users.py index a983975..5db7156 100644 --- a/ref-test/admin/models/users.py +++ b/ref-test/admin/models/users.py @@ -6,8 +6,8 @@ from werkzeug.utils import redirect from flask_mail import Message import secrets -from security import encrypt, decrypt -from security.database import decrypt_find_one, encrypted_update +from common.security import encrypt, decrypt +from common.security.database import decrypt_find_one, encrypted_update from datetime import datetime, timedelta from main import db, mail diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/blueprints.py b/ref-test/common/blueprints.py new file mode 100644 index 0000000..d15e272 --- /dev/null +++ b/ref-test/common/blueprints.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta +from flask import Blueprint, redirect, request + +cookie_consent = Blueprint( + 'cookie_consent', + __name__ +) +@cookie_consent.route('/') +def _cookies(): + resp = redirect('/') + resp.set_cookie( + key = 'cookie_consent', + value = 'True', + max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', + path = '/', + expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' + ) + return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From e37d2873977050e4f72b8fff2164b0fc1433a3e3 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 005/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 15 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From 53cc25b4ce92783e7d9929c1634be57aeca0ed27 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 006/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/models/users.py | 4 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/blueprints.py | 18 +++++ ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 17 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 ref-test/common/blueprints.py create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/models/users.py b/ref-test/admin/models/users.py index a983975..5db7156 100644 --- a/ref-test/admin/models/users.py +++ b/ref-test/admin/models/users.py @@ -6,8 +6,8 @@ from werkzeug.utils import redirect from flask_mail import Message import secrets -from security import encrypt, decrypt -from security.database import decrypt_find_one, encrypted_update +from common.security import encrypt, decrypt +from common.security.database import decrypt_find_one, encrypted_update from datetime import datetime, timedelta from main import db, mail diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/blueprints.py b/ref-test/common/blueprints.py new file mode 100644 index 0000000..d15e272 --- /dev/null +++ b/ref-test/common/blueprints.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta +from flask import Blueprint, redirect, request + +cookie_consent = Blueprint( + 'cookie_consent', + __name__ +) +@cookie_consent.route('/') +def _cookies(): + resp = redirect('/') + resp.set_cookie( + key = 'cookie_consent', + value = 'True', + max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', + path = '/', + expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' + ) + return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From e0eda9df4954d8fc061e89e95a072fc49fd5da61 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 007/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 15 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From 60b6a462c8a2ba0d1376b7eac16461683047d2f6 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 02:30:46 +0000 Subject: [PATCH 008/504] Finished data upload Refactored to move security package inside common Moved data folder to process root. --- REFERENCES.md | 4 + ref-test/admin/auth.py | 2 +- ref-test/admin/models/forms.py | 6 +- ref-test/admin/models/tests.py | 2 +- ref-test/admin/models/users.py | 4 +- ref-test/admin/static/js/script.js | 81 ++++++++++++++++--- .../templates/admin/settings/questions.html | 24 +++++- ref-test/admin/views.py | 23 +++++- ref-test/common/__init__.py | 18 ----- ref-test/common/blueprints.py | 18 +++++ ref-test/common/data_tools.py | 59 ++++++++++++++ ref-test/{ => common}/security/__init__.py | 6 +- ref-test/{ => common}/security/database.py | 0 ref-test/config.py | 1 + ref-test/data/.gitignore | 2 + ref-test/main.py | 4 +- ref-test/quiz/views.py | 2 +- 17 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 ref-test/common/blueprints.py create mode 100644 ref-test/common/data_tools.py rename ref-test/{ => common}/security/__init__.py (86%) rename ref-test/{ => common}/security/database.py (100%) create mode 100644 ref-test/data/.gitignore diff --git a/REFERENCES.md b/REFERENCES.md index 1f58c48..56aed1c 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -77,3 +77,7 @@ Uses SQL rather than MongoDB. ### Flask techniques - [Create a `config.py` file](https://www.youtube.com/watch?v=GW_2O9CrnSU) + +### Flask handling file uploads + +- [Handlin File Uploads](https://blog.miguelgrinberg.com/post/handling-file-uploads-with-flask) \ No newline at end of file diff --git a/ref-test/admin/auth.py b/ref-test/admin/auth.py index 2c6e259..7950c99 100644 --- a/ref-test/admin/auth.py +++ b/ref-test/admin/auth.py @@ -3,7 +3,7 @@ from flask.helpers import flash, url_for from flask.json import jsonify from .models.users import User from uuid import uuid4 -from security.database import decrypt_find_one, encrypted_update +from common.security.database import decrypt_find_one, encrypted_update from werkzeug.security import check_password_hash from main import db diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index c63b8cb..9e762f9 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -1,4 +1,5 @@ from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileRequired, FileAllowed from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta @@ -53,4 +54,7 @@ class CreateTest(FlaskForm): ('120', '2 hours') ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) - time_limit = SelectField('Time Limit', choices=time_options) \ No newline at end of file + time_limit = SelectField('Time Limit', choices=time_options) + +class UploadDataForm(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index ce26428..45196a7 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -5,7 +5,7 @@ from flask import flash, jsonify import secrets from main import db -from security import encrypt +from common.security import encrypt class Test: def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): diff --git a/ref-test/admin/models/users.py b/ref-test/admin/models/users.py index a983975..5db7156 100644 --- a/ref-test/admin/models/users.py +++ b/ref-test/admin/models/users.py @@ -6,8 +6,8 @@ from werkzeug.utils import redirect from flask_mail import Message import secrets -from security import encrypt, decrypt -from security.database import decrypt_find_one, encrypted_update +from common.security import encrypt, decrypt +from common.security.database import decrypt_find_one, encrypted_update from datetime import datetime, timedelta from main import db, mail diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index ad97048..e6e4d73 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -350,13 +350,18 @@ $('form[name=form-update-account]').submit(function(event) { event.preventDefault(); }); -$('.delete-test').click(function(event) { +$('form[name=form-create-test]').submit(function(event) { - _id = $(this).data('_id') + var $form = $(this); + var alert = document.getElementById('alert-box'); + var data = $form.serialize(); + alert.innerHTML = '' $.ajax({ - url: `/admin/tests/delete/${_id}`, - type: 'GET', + url: window.location.pathname, + type: 'POST', + data: data, + dataType: 'json', success: function(response) { window.location.href = '/admin/tests/'; }, @@ -386,20 +391,78 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); -// Edit and Delete Test Button Handlers - -$('form[name=form-create-test]').submit(function(event) { +$('form[name=form-upload-questions]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); - var data = $form.serialize(); + var data = new FormData($form[0]); + var file = $('input[name=data_file]')[0].files[0] + data.append('file', file) alert.innerHTML = '' $.ajax({ url: window.location.pathname, type: 'POST', data: data, - dataType: 'json', + processData: false, + contentType: false, + success: function(response) { + if (typeof response.success === 'string' || response.success instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.success instanceof Array) { + for (var i = 0; i < response.success.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + + event.preventDefault(); +}); + + +// Edit and Delete Test Button Handlers + +$('.delete-test').click(function(event) { + + _id = $(this).data('_id') + + $.ajax({ + url: `/admin/tests/delete/${_id}`, + type: 'GET', success: function(response) { window.location.href = '/admin/tests/'; }, diff --git a/ref-test/admin/templates/admin/settings/questions.html b/ref-test/admin/templates/admin/settings/questions.html index 9624223..d4892e3 100644 --- a/ref-test/admin/templates/admin/settings/questions.html +++ b/ref-test/admin/templates/admin/settings/questions.html @@ -1 +1,23 @@ -{% extends "admin/components/base.html" %} \ No newline at end of file +{% extends "admin/components/base.html" %} +{% block title %} SKA Referee Test | Upload Questions {% endblock %} +{% block content %} + +
+ +
+{% endblock %} \ No newline at end of file diff --git a/ref-test/admin/views.py b/ref-test/admin/views.py index 6bc4ce4..43edb22 100644 --- a/ref-test/admin/views.py +++ b/ref-test/admin/views.py @@ -1,9 +1,10 @@ from flask import Blueprint, render_template, flash, redirect, request, jsonify, abort from flask.helpers import url_for from functools import wraps +from datetime import datetime from werkzeug.security import check_password_hash -from security.database import decrypt_find, decrypt_find_one +from common.security.database import decrypt_find, decrypt_find_one from .models.users import User from flask_mail import Message from main import db @@ -231,11 +232,27 @@ def update_user(_id:str): errors = [*form.user_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] return jsonify({ 'error': errors}), 400 -@views.route('/settings/questions/') +@views.route('/settings/questions/', methods=['GET', 'POST']) @admin_account_required @login_required def questions(): - return render_template('/admin/settings/questions.html') + from main import app + from .models.forms import UploadDataForm + from common.data_tools import check_json_format, validate_json_contents, store_data_file + form = UploadDataForm() + if request.method == 'GET': + return render_template('/admin/settings/questions.html', form=form) + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_json_format(upload): + return jsonify({ 'error': 'Invalid file selected. Please upload a JSON file.'}), 400 + if not validate_json_contents(upload): + return jsonify({'error': 'The data in the file is invalid.'}), 400 + store_data_file(upload) + return jsonify({ 'success': 'File uploaded.'}), 200 + errors = [*form.errors] + return jsonify({ 'error': errors}), 400 @views.route('/settings/questions/upload/') @admin_account_required diff --git a/ref-test/common/__init__.py b/ref-test/common/__init__.py index d15e272..e69de29 100644 --- a/ref-test/common/__init__.py +++ b/ref-test/common/__init__.py @@ -1,18 +0,0 @@ -from datetime import datetime, timedelta -from flask import Blueprint, redirect, request - -cookie_consent = Blueprint( - 'cookie_consent', - __name__ -) -@cookie_consent.route('/') -def _cookies(): - resp = redirect('/') - resp.set_cookie( - key = 'cookie_consent', - value = 'True', - max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', - path = '/', - expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' - ) - return resp \ No newline at end of file diff --git a/ref-test/common/blueprints.py b/ref-test/common/blueprints.py new file mode 100644 index 0000000..d15e272 --- /dev/null +++ b/ref-test/common/blueprints.py @@ -0,0 +1,18 @@ +from datetime import datetime, timedelta +from flask import Blueprint, redirect, request + +cookie_consent = Blueprint( + 'cookie_consent', + __name__ +) +@cookie_consent.route('/') +def _cookies(): + resp = redirect('/') + resp.set_cookie( + key = 'cookie_consent', + value = 'True', + max_age = timedelta(days=14) if request.cookies.get('remember') == 'True' else 'Session', + path = '/', + expires = datetime.utcnow() + timedelta(days=14) if request.cookies.get('remember') else 'Session' + ) + return resp \ No newline at end of file diff --git a/ref-test/common/data_tools.py b/ref-test/common/data_tools.py new file mode 100644 index 0000000..dbc92e8 --- /dev/null +++ b/ref-test/common/data_tools.py @@ -0,0 +1,59 @@ +import os +from shutil import rmtree +import pathlib +from json import dump, loads +from datetime import datetime +from main import app + +from werkzeug.utils import secure_filename + +def check_data_folder_exists(): + if not os.path.exists(app.config['DATA_FILE_DIRECTORY']): + pathlib.Path(app.config['DATA_FILE_DIRECTORY']).mkdir(parents='True', exist_ok='True') + +def check_current_indicator(): + if not os.path.isfile(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt')): + open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'),'w').close() + +def make_temp_dir(file): + if not os.path.isdir('tmp'): + os.mkdir('tmp') + if os.path.isfile(f'tmp/{file.filename}'): + os.remove(f'tmp/{file.filename}') + file.save(f'tmp/{file.filename}') + +def check_json_format(file): + if not '.' in file.filename: + return False + if not file.filename.rsplit('.', 1)[-1] == 'json': + return False + return True + +def validate_json_contents(file): + file.stream.seek(0) + data = loads(file.read()) + if not type(data) is dict: + return False + elif not all( key in data for key in ['meta', 'questions']): + return False + elif not type(data['meta']) is dict: + return False + elif not type(data['questions']) is list: + return False + return True + +def store_data_file(file): + from admin.views import get_id_from_cookie + check_current_indicator() + timestamp = datetime.utcnow() + filename = '.'.join([timestamp.strftime('%Y%m%d%H%M%S'),'json']) + filename = secure_filename(filename) + file_path = os.path.join(app.config['DATA_FILE_DIRECTORY'], filename) + file.stream.seek(0) + data = loads(file.read()) + data['meta']['timestamp'] = timestamp.strftime('%Y-%m-%d %H%M%S') + data['meta']['author'] = get_id_from_cookie() + with open(file_path, 'w') as _file: + dump(data, _file, indent=4) + with open(os.path.join(app.config['DATA_FILE_DIRECTORY'], '.current.txt'), 'w') as _file: + _file.write(filename) \ No newline at end of file diff --git a/ref-test/security/__init__.py b/ref-test/common/security/__init__.py similarity index 86% rename from ref-test/security/__init__.py rename to ref-test/common/security/__init__.py index 83d22c2..0272741 100644 --- a/ref-test/security/__init__.py +++ b/ref-test/common/security/__init__.py @@ -2,17 +2,17 @@ from os import environ, path from cryptography.fernet import Fernet def generate_keyfile(): - with open('./security/.encryption.key', 'wb') as keyfile: + with open('./common/security/.encryption.key', 'wb') as keyfile: key = Fernet.generate_key() keyfile.write(key) def load_key(): - with open('./security/.encryption.key', 'rb') as keyfile: + with open('./common/security/.encryption.key', 'rb') as keyfile: key = keyfile.read() return key def check_keyfile_exists(): - return path.isfile('./security/.encryption.key') + return path.isfile('./common/security/.encryption.key') def encrypt(input): if not check_keyfile_exists(): diff --git a/ref-test/security/database.py b/ref-test/common/security/database.py similarity index 100% rename from ref-test/security/database.py rename to ref-test/common/security/database.py diff --git a/ref-test/config.py b/ref-test/config.py index d394ce9..a1012c5 100644 --- a/ref-test/config.py +++ b/ref-test/config.py @@ -26,6 +26,7 @@ class Config(object): MAIL_MAX_EMAILS = int(os.getenv("MAIL_MAX_EMAILS")) MAIL_SUPPRESS_SEND = False MAIL_ASCII_ATTACHMENTS = bool(os.getenv("MAIL_ASCII_ATTACHMENTS")) + DATA_FILE_DIRECTORY = os.getenv("DATA_FILE_DIRECTORY") class ProductionConfig(Config): pass diff --git a/ref-test/data/.gitignore b/ref-test/data/.gitignore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/ref-test/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/ref-test/main.py b/ref-test/main.py index 2a3173c..d2f4eb3 100644 --- a/ref-test/main.py +++ b/ref-test/main.py @@ -9,7 +9,7 @@ from pymongo.errors import ConnectionFailure from flask_wtf.csrf import CSRFProtect, CSRFError from flask_mail import Mail -from security import check_keyfile_exists, generate_keyfile +from common.security import check_keyfile_exists, generate_keyfile app = Flask(__name__) app.config.from_object('config.DevelopmentConfig') @@ -37,7 +37,7 @@ if __name__ == '__main__': if not check_keyfile_exists(): generate_keyfile() - from common import cookie_consent + from common.blueprints import cookie_consent from admin.views import views as admin_views from admin.auth import auth as admin_auth diff --git a/ref-test/quiz/views.py b/ref-test/quiz/views.py index 3f0bb35..cd88bd2 100644 --- a/ref-test/quiz/views.py +++ b/ref-test/quiz/views.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import uuid4 from main import db -from security import encrypt +from common.security import encrypt views = Blueprint( 'quiz_views', From 6929136f905a04b8b1d5811209ed5459219cbcd9 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sun, 28 Nov 2021 17:28:14 +0000 Subject: [PATCH 009/504] Added functionality for default datasets. Incorporated dataset selector into test creation. --- ref-test/admin/models/forms.py | 4 +- ref-test/admin/models/tests.py | 24 +++- ref-test/admin/static/css/style.css | 18 +-- ref-test/admin/static/js/script.js | 131 +++++++++++++----- .../templates/admin/components/navbar.html | 2 +- .../templates/admin/settings/questions.html | 112 ++++++++++++++- .../admin/templates/admin/settings/users.html | 4 +- ref-test/admin/templates/admin/tests.html | 8 +- ref-test/admin/views.py | 94 +++++++++++-- ref-test/common/data_tools.py | 32 +++-- ref-test/quiz/views.py | 6 +- 11 files changed, 350 insertions(+), 85 deletions(-) diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms.py index 9e762f9..ee05b74 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms.py @@ -55,6 +55,8 @@ class CreateTest(FlaskForm): ] expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) time_limit = SelectField('Time Limit', choices=time_options) + dataset = SelectField('Question Dataset') class UploadDataForm(FlaskForm): - data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) \ No newline at end of file + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) + default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index 45196a7..a0e44cd 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -3,17 +3,20 @@ from datetime import datetime from uuid import uuid4 from flask import flash, jsonify import secrets +import os +from json import dump, loads -from main import db +from main import app, db from common.security import encrypt class Test: - def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None): + def __init__(self, _id=None, start_date=None, expiry_date=None, time_limit=None, creator=None, dataset=None): self._id = _id self.start_date = start_date self.expiry_date = expiry_date self.time_limit = None if time_limit == 'none' or time_limit == '' else time_limit self.creator = creator + self.dataset = dataset def create(self): test = { @@ -23,9 +26,16 @@ class Test: 'expiry_date': self.expiry_date, 'time_limit': self.time_limit, 'creator': encrypt(self.creator), - 'test_code': secrets.token_hex(6).upper() + 'test_code': secrets.token_hex(6).upper(), + 'dataset': self.dataset } if db.tests.insert_one(test): + dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset) + with open(dataset_file_path, 'r') as dataset_file: + data = loads(dataset_file.read()) + data['meta']['tests'].append(self._id) + with open(dataset_file_path, 'w') as dataset_file: + dump(data, dataset_file, indent=2) flash(f'Created a new exam with Exam Code {self.render_test_code(test["test_code"])}.', 'success') return jsonify({'success': test}), 200 return jsonify({'error': f'Could not create exam. An error occurred.'}), 400 @@ -54,7 +64,15 @@ class Test: return test_code.replace('—', '') def delete(self): + if self.dataset is None: + self.dataset = db.tests.find_one({'_id': self._id})['dataset'] if db.tests.delete_one({'_id': self._id}): + dataset_file_path = os.path.join(app.config["DATA_FILE_DIRECTORY"],self.dataset) + with open(dataset_file_path, 'r') as dataset_file: + data = loads(dataset_file.read()) + data['meta']['tests'].remove(self._id) + with open(dataset_file_path, 'w') as dataset_file: + dump(data, dataset_file, indent=2) message = 'Deleted exam.' flash(message, 'alert') return jsonify({'success': message}), 200 diff --git a/ref-test/admin/static/css/style.css b/ref-test/admin/static/css/style.css index 083c8fd..d719dc2 100644 --- a/ref-test/admin/static/css/style.css +++ b/ref-test/admin/static/css/style.css @@ -132,16 +132,11 @@ table.dataTable { width: 100%; } -.user-table-row { +.table-row { vertical-align: middle; } -.user-row-actions { - text-align: center; - white-space: nowrap; -} - -.test-row-actions { +.row-actions { text-align: center; white-space: nowrap; } @@ -153,8 +148,8 @@ table.dataTable { text-align:center; } -.user-row-actions button { - margin: 0px 10px; +.row-actions button, .row-actions a { + margin: 0px 5px; } #cookie-alert { @@ -214,6 +209,11 @@ table.dataTable { font-size: 20px; } +.form-upload { + margin: 2rem 0; + font-size: 14pt; +} + /* Fallback for Edge -------------------------------------------------- */ @supports (-ms-ime-align: auto) { diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index e6e4d73..ad4cd34 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -20,7 +20,7 @@ $('form[name=form-register]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -62,7 +62,7 @@ $('form[name=form-login]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -104,7 +104,7 @@ $('form[name=form-reset]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -146,7 +146,7 @@ $('form[name=form-update-password]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); console.log(data) - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -188,7 +188,7 @@ $('form[name=form-create-user]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -230,7 +230,7 @@ $('form[name=form-delete-user]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -272,7 +272,7 @@ $('form[name=form-update-user]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -314,7 +314,7 @@ $('form[name=form-update-account]').submit(function(event) { var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -355,7 +355,7 @@ $('form[name=form-create-test]').submit(function(event) { var $form = $(this); var alert = document.getElementById('alert-box'); var data = $form.serialize(); - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -398,7 +398,7 @@ $('form[name=form-upload-questions]').submit(function(event) { var data = new FormData($form[0]); var file = $('input[name=data_file]')[0].files[0] data.append('file', file) - alert.innerHTML = '' + alert.innerHTML = ''; $.ajax({ url: window.location.pathname, @@ -407,25 +407,7 @@ $('form[name=form-upload-questions]').submit(function(event) { processData: false, contentType: false, success: function(response) { - if (typeof response.success === 'string' || response.success instanceof String) { - alert.innerHTML = alert.innerHTML + ` - - `; - } else if (response.success instanceof Array) { - for (var i = 0; i < response.success.length; i ++) { - alert.innerHTML = alert.innerHTML + ` - - `; - } - } + window.location.reload(); }, error: function(response) { if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { @@ -455,10 +437,10 @@ $('form[name=form-upload-questions]').submit(function(event) { // Edit and Delete Test Button Handlers - $('.delete-test').click(function(event) { _id = $(this).data('_id') + $.ajax({ url: `/admin/tests/delete/${_id}`, @@ -492,6 +474,89 @@ $('.delete-test').click(function(event) { event.preventDefault(); }); +// Edit and Delete Dataset Button Handlers +$('.delete-question-dataset').click(function(event) { + + var alert = document.getElementById('alert-box'); + alert.innerHTML = ''; + + var filename = $(this).data('filename'); + var disabled = $(this).hasClass('disabled'); + + if ( !disabled ) { + $.ajax({ + url: `/admin/settings/questions/delete/${filename}`, + type: 'GET', + success: function(response) { + window.location.reload(); + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + }; + event.preventDefault(); +}); + +$('.edit-question-dataset').click(function(event) { + + var alert = document.getElementById('alert-box'); + alert.innerHTML = ''; + + var filename = $(this).data('filename'); + var disabled = $(this).hasClass('disabled'); + + if ( !disabled ) { + $.ajax({ + url: `/admin/settings/questions/default/${filename}`, + type: 'GET', + success: function(response) { + window.location.reload(); + }, + error: function(response) { + if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { + alert.innerHTML = alert.innerHTML + ` + + `; + } else if (response.responseJSON.error instanceof Array) { + for (var i = 0; i < response.responseJSON.error.length; i ++) { + alert.innerHTML = alert.innerHTML + ` + + `; + } + } + } + }); + }; + event.preventDefault(); +}); + // Dismiss Cookie Alert $('#dismiss-cookie-alert').click(function(event){ @@ -503,13 +568,13 @@ $('#dismiss-cookie-alert').click(function(event){ }, dataType: 'json', success: function(response){ - console.log(response) + console.log(response); }, error: function(response){ - console.log(response) + console.log(response); } }) - event.preventDefault() + event.preventDefault(); }) \ No newline at end of file diff --git a/ref-test/admin/templates/admin/components/navbar.html b/ref-test/admin/templates/admin/components/navbar.html index 12fe70a..7fa655f 100644 --- a/ref-test/admin/templates/admin/components/navbar.html +++ b/ref-test/admin/templates/admin/components/navbar.html @@ -24,7 +24,7 @@ View Results
  • From d890a45f2b5305d88e7c326a8434ff0c4b5bc86a Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 12:48:01 +0000 Subject: [PATCH 146/504] Typo correction --- ref-test/admin/templates/admin/result-detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/admin/templates/admin/result-detail.html b/ref-test/admin/templates/admin/result-detail.html index 44b264b..b737789 100644 --- a/ref-test/admin/templates/admin/result-detail.html +++ b/ref-test/admin/templates/admin/result-detail.html @@ -13,7 +13,7 @@
    Candidate

    - {{ entry.name.surname}}, {{ entry.name.first_name }} + {{ entry.name.surname }}, {{ entry.name.first_name }}

  • From ba082d4ed7515e108469032cb906df5402750c46 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 12:48:01 +0000 Subject: [PATCH 147/504] Typo correction --- ref-test/admin/templates/admin/result-detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/admin/templates/admin/result-detail.html b/ref-test/admin/templates/admin/result-detail.html index 44b264b..b737789 100644 --- a/ref-test/admin/templates/admin/result-detail.html +++ b/ref-test/admin/templates/admin/result-detail.html @@ -13,7 +13,7 @@
    Candidate

    - {{ entry.name.surname}}, {{ entry.name.first_name }} + {{ entry.name.surname }}, {{ entry.name.first_name }}

  • From b92b1c7c32c7c37818a91a68b9b80135acf127d5 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 12:48:01 +0000 Subject: [PATCH 148/504] Typo correction --- ref-test/admin/templates/admin/result-detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/admin/templates/admin/result-detail.html b/ref-test/admin/templates/admin/result-detail.html index 44b264b..b737789 100644 --- a/ref-test/admin/templates/admin/result-detail.html +++ b/ref-test/admin/templates/admin/result-detail.html @@ -13,7 +13,7 @@
    Candidate

    - {{ entry.name.surname}}, {{ entry.name.first_name }} + {{ entry.name.surname }}, {{ entry.name.first_name }}

  • From 4b671242ffc9f81f1c1fe0e8ab2bcf9a52adafb3 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 12:48:01 +0000 Subject: [PATCH 149/504] Typo correction --- ref-test/admin/templates/admin/result-detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/admin/templates/admin/result-detail.html b/ref-test/admin/templates/admin/result-detail.html index 44b264b..b737789 100644 --- a/ref-test/admin/templates/admin/result-detail.html +++ b/ref-test/admin/templates/admin/result-detail.html @@ -13,7 +13,7 @@
    Candidate

    - {{ entry.name.surname}}, {{ entry.name.first_name }} + {{ entry.name.surname }}, {{ entry.name.first_name }}

  • From 121dd32bfb17681a3cc22addeaa4d2d85812a16b Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 12:48:01 +0000 Subject: [PATCH 150/504] Typo correction --- ref-test/admin/templates/admin/result-detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/admin/templates/admin/result-detail.html b/ref-test/admin/templates/admin/result-detail.html index 44b264b..b737789 100644 --- a/ref-test/admin/templates/admin/result-detail.html +++ b/ref-test/admin/templates/admin/result-detail.html @@ -13,7 +13,7 @@
    Candidate

    - {{ entry.name.surname}}, {{ entry.name.first_name }} + {{ entry.name.surname }}, {{ entry.name.first_name }}

  • From f47e22ccae4cd768acb8bc5948a33a3bc57e70d8 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 12:48:01 +0000 Subject: [PATCH 151/504] Typo correction --- ref-test/admin/templates/admin/result-detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/admin/templates/admin/result-detail.html b/ref-test/admin/templates/admin/result-detail.html index 44b264b..b737789 100644 --- a/ref-test/admin/templates/admin/result-detail.html +++ b/ref-test/admin/templates/admin/result-detail.html @@ -13,7 +13,7 @@
    Candidate

    - {{ entry.name.surname}}, {{ entry.name.first_name }} + {{ entry.name.surname }}, {{ entry.name.first_name }}

  • From c7ca26202e76ac658b689071cdb3d307944d5b8f Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 12:48:01 +0000 Subject: [PATCH 152/504] Typo correction --- ref-test/admin/templates/admin/result-detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ref-test/admin/templates/admin/result-detail.html b/ref-test/admin/templates/admin/result-detail.html index 44b264b..b737789 100644 --- a/ref-test/admin/templates/admin/result-detail.html +++ b/ref-test/admin/templates/admin/result-detail.html @@ -13,7 +13,7 @@
    Candidate

    - {{ entry.name.surname}}, {{ entry.name.first_name }} + {{ entry.name.surname }}, {{ entry.name.first_name }}

  • From 4902d40787b738b4d67241cb4db06a7eeca4663a Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 153/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 4b603b70a07985313b2d468ad7bc6d63b45823b3 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 154/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 8f8a12b6097f2a498f64e292a7dcea12d127c9e8 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 155/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 707398eae2a8c21429dd15175e0b729a77a123a2 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 156/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 49b0ea14f0de9e00e7fb811584ada0a867641017 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 157/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 56b3e6a2f561d2b534f475a21550383ba82b0741 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 158/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 0eda083bf2f66365787efe1a93a73830f0fbb594 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 159/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 6b01841529ab278fa21f139207e91d3221147c88 Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:24 +0000 Subject: [PATCH 160/504] Refactored form model for custom validators --- ref-test/admin/models/{forms.py => forms/__init__.py} | 10 ++++++++-- ref-test/admin/models/forms/validators.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) rename ref-test/admin/models/{forms.py => forms/__init__.py} (95%) create mode 100644 ref-test/admin/models/forms/validators.py diff --git a/ref-test/admin/models/forms.py b/ref-test/admin/models/forms/__init__.py similarity index 95% rename from ref-test/admin/models/forms.py rename to ref-test/admin/models/forms/__init__.py index 9ca6e1e..5bf60a4 100644 --- a/ref-test/admin/models/forms.py +++ b/ref-test/admin/models/forms/__init__.py @@ -1,9 +1,11 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired, FileAllowed -from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField +from wtforms import StringField, PasswordField, BooleanField, DateField, SelectField, IntegerField from wtforms.validators import InputRequired, Email, Length, EqualTo, Optional from datetime import date, timedelta +from .validators import value + class LoginForm(FlaskForm): username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) @@ -53,4 +55,8 @@ class CreateTest(FlaskForm): class UploadDataForm(FlaskForm): data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) - default = BooleanField('Make Default', render_kw={'checked': True}) \ No newline at end of file + default = BooleanField('Make Default', render_kw={'checked': True}) + +class AddTimeAdjustment(FlaskForm): + time = IntegerField('Extra Time (Minutes)', validators=[InputRequired(), value(max=60)]) + \ No newline at end of file diff --git a/ref-test/admin/models/forms/validators.py b/ref-test/admin/models/forms/validators.py new file mode 100644 index 0000000..7caeb05 --- /dev/null +++ b/ref-test/admin/models/forms/validators.py @@ -0,0 +1,11 @@ +from wtforms.validators import ValidationError + +def value(min=0, max=None): + message = f'Value must be between {min} and {max}.' + + def _length(form, field): + value = field.data or 0 + if value < min or max != None and value > max: + raise ValidationError(message) + + return _length \ No newline at end of file From 6855ddfdcb6550dcbd5c6186655dd5ba88cccabf Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Sat, 4 Dec 2021 15:41:47 +0000 Subject: [PATCH 161/504] Added server and admin-side time limit adjustments --- ref-test/admin/models/tests.py | 9 +- ref-test/admin/static/css/style.css | 6 +- ref-test/admin/static/js/script.js | 76 ++++++-- .../admin/components/client-alerts.html | 2 +- ref-test/admin/templates/admin/test.html | 180 ++++++++++++++++++ ref-test/admin/templates/admin/tests.html | 6 +- ref-test/admin/views.py | 24 ++- 7 files changed, 270 insertions(+), 33 deletions(-) create mode 100644 ref-test/admin/templates/admin/test.html diff --git a/ref-test/admin/models/tests.py b/ref-test/admin/models/tests.py index 656c424..a1b3397 100644 --- a/ref-test/admin/models/tests.py +++ b/ref-test/admin/models/tests.py @@ -41,13 +41,14 @@ class Test: return jsonify({'error': f'Could not create exam. An error occurred.'}), 400 def add_time_adjustment(self, time_adjustment): - code = { + adjustment = { '_id': uuid4().hex, - 'user_code': secrets.token_hex(2).upper(), + 'user_code': secrets.token_hex(3).upper(), 'time_adjustment': time_adjustment } - if db.tests.find_one_and_update({'_id': self._id}, {'$push': {'time_adjustments': code}},upsert=False): - return jsonify({'success': code}) + if db.tests.find_one_and_update({'_id': self._id}, {'$push': {'time_adjustments': adjustment}},upsert=False): + flash(f'Time adjustment for {adjustment["time_adjustment"]} minutes has been added. This can be enabled using the user code {adjustment["user_code"]}.') + return jsonify({'success': adjustment}) return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400 def remove_time_adjustment(self, _id): diff --git a/ref-test/admin/static/css/style.css b/ref-test/admin/static/css/style.css index 8107688..137d546 100644 --- a/ref-test/admin/static/css/style.css +++ b/ref-test/admin/static/css/style.css @@ -214,11 +214,15 @@ table.dataTable { font-size: 14pt; } -.result-action-buttons { +.result-action-buttons, .test-action { margin: 5px auto; width: fit-content; } +.accordion-item { + background-color: unset; +} + /* Fallback for Edge -------------------------------------------------- */ @supports (-ms-ime-align: auto) { diff --git a/ref-test/admin/static/js/script.js b/ref-test/admin/static/js/script.js index d3362b5..184374a 100644 --- a/ref-test/admin/static/js/script.js +++ b/ref-test/admin/static/js/script.js @@ -63,22 +63,27 @@ $('form[name=form-upload-questions]').submit(function(event) { }); // Edit and Delete Test Button Handlers -$('.delete-test').click(function(event) { +$('.test-action').click(function(event) { - let _id = $(this).data('_id') + let _id = $(this).data('_id'); + let action = $(this).data('action'); - $.ajax({ - url: `/admin/tests/delete/`, - type: 'POST', - data: JSON.stringify({'_id': _id}), - contentType: 'application/json', - success: function(response) { - window.location.href = '/admin/tests/'; - }, - error: function(response){ - error_response(response); - }, - }); + if (action == 'delete') { + $.ajax({ + url: `/admin/tests/delete/`, + type: 'POST', + data: JSON.stringify({'_id': _id}), + contentType: 'application/json', + success: function(response) { + window.location.href = '/admin/tests/'; + }, + error: function(response){ + error_response(response); + }, + }); + } else if (action == 'edit') { + window.location.href = `/admin/test/${_id}/` + } event.preventDefault(); }); @@ -109,11 +114,11 @@ $('.edit-question-dataset').click(function(event) { function error_response(response) { - var alert = $("#alert-box"); - alert.html(''); + const $alert = $("#alert-box"); + $alert.html(''); if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) { - alert.html(` + $alert.html(`