From 126bf9203c3d8715694abc208d6388af88c3bcfb Mon Sep 17 00:00:00 2001 From: viveksantayana Date: Tue, 14 Jun 2022 22:55:11 +0100 Subject: [PATCH] Added a whole lot of views. Finished quiz API views Finished question generator and answer eval --- ref-test/app/admin/views.py | 97 ++++++++++++++++++++++++-- ref-test/app/api/views.py | 58 +++++++++++++++- ref-test/app/install.py | 1 + ref-test/app/models/__init__.py | 3 +- ref-test/app/models/dataset.py | 82 ++++++++++++++++++++++ ref-test/app/models/entry.py | 15 +++- ref-test/app/models/test.py | 19 ++++- ref-test/app/models/user.py | 12 ++-- ref-test/app/templates/privacy.html | 1 + ref-test/app/tools/data.py | 26 ++++++- ref-test/app/tools/forms.py | 11 ++- ref-test/app/tools/test.py | 104 +++++++++++++++++++++++++++- ref-test/app/views.py | 12 ++++ ref-test/app/views/__init__.py | 10 --- ref-test/app/views/privacy.py | 5 -- 15 files changed, 421 insertions(+), 35 deletions(-) create mode 100644 ref-test/app/models/dataset.py create mode 100644 ref-test/app/templates/privacy.html create mode 100644 ref-test/app/views.py delete mode 100644 ref-test/app/views/__init__.py delete mode 100644 ref-test/app/views/privacy.py diff --git a/ref-test/app/admin/views.py b/ref-test/app/admin/views.py index dbaa0d9..ace2faa 100644 --- a/ref-test/app/admin/views.py +++ b/ref-test/app/admin/views.py @@ -1,6 +1,7 @@ -from ..forms.admin import CreateUser, Login, Register, ResetPassword, UpdatePassword -from ..models import User +from ..forms.admin import CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData +from ..models import Dataset, User from ..tools.auth import disable_if_logged_in, require_account_creation +from ..tools.data import check_is_json, validate_json from flask import Blueprint, flash, jsonify, render_template, redirect, request, session from flask.helpers import url_for @@ -18,12 +19,16 @@ admin = Blueprint( @admin.route('/') @admin.route('/home/') @admin.route('/dashboard/') +@login_required def _home(): - return 'Home Page' + return 'Home Page' # TODO Dashboard @admin.route('/settings/') +@login_required def _settings(): - return 'Settings Page' + users = User.query.all() + datasets = Dataset.query.all() + return render_template('/admin/settings/index.html', users=users, datasets=datasets) @admin.route('/login/', methods=['GET','POST']) @disable_if_logged_in @@ -64,7 +69,6 @@ def _register(): if request.method == 'POST': if form.validate_on_submit(): new_user = User() - new_user.generate_id() new_user.set_username = request.form.get('username').lower() new_user.set_email = request.form.get('email').lower() new_user.set_password = request.form.get('password').lower() @@ -140,3 +144,86 @@ def _users(): errors = [*form.username.errors, *form.email.errors, *form.password.errors] return jsonify({ 'error': errors}), 401 return render_template('/admin/settings/users.html', form = form, users = users) + +@admin.route('/settings/users/delete/', methods=['GET', 'POST']) +@login_required +def _delete_user(id:str): + user = User.query.filter_by(id=id).first() + form = DeleteUser() + if request.method == 'POST': + if not user: return jsonify({'error': 'User does not exist.'}), 400 + if id == current_user.id: return jsonify({'error': 'Cannot delete your own account.'}), 400 + if form.validate_on_submit(): + password = request.form.get('password') + if not current_user.verify_password(password): return jsonify({'error': 'The password you entered is incorrect.'}), 401 + success, message = user.delete(notify=request.form.get('notify')) + if success: return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + errors = form.password.errors + return jsonify({ 'error': errors}), 400 + + if id == current_user.id: + flash('Cannot delete your own user account.', 'error') + return redirect(url_for('admin._users')) + if not user: + flash('User not found.', 'error') + return redirect(url_for('admin._users')) + return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user) + +@admin.route('/settings/users/update/', methods=['GET', 'POST']) +@login_required +def _update_user(id:str): + user = User.query.filter_by(id=id).first() + form = UpdateUser() + if request.method == 'POST': + if not user: return jsonify({'error': 'User does not exist.'}), 400 + if form.validate_on_submit(): + success, message = user.update( + password = request.form.get('password'), + email = request.form.get('email'), + notify = request.form.get('notify') + ) + if success: return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + errors = [*form.confirm_password.errors, *form.email.errors, *form.password.errors, *form.password_reenter.errors] + return jsonify({ 'error': errors}), 400 + if not user: + flash('User not found.', 'error') + return redirect(url_for('admin._users')) + return render_template('/admin/settings/delete_user.html', form=form, id = id, user = user) + +@admin.route('/settings/questions/', methods=['GET', 'POST']) +@login_required +def _quesitons(): + form = UploadData() + if request.method == 'POST': + if form.validate_on_submit(): + upload = form.data_file.data + if not check_is_json(upload): return jsonify({'error': 'Invalid file. Please upload a JSON file.'}), 400 + if not validate_json(upload): return jsonify({'error': 'The data in the file is invalid.'}), 400 # TODO Perhaps make a more complex validation script + new_dataset = Dataset() + success, message = new_dataset.create( + upload = upload, + default = request.form.get('default') + ) + if success: return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + errors = form.data_file.errors + return jsonify({ 'error': errors}), 400 + + data = Dataset.query.all() + return render_template('/admin/settings/questions.html', data=data) + +@admin.route('/settings/questions/edit/', methods=['POST']) +@login_required +def _delete_questions(): + id = request.get_json()['id'] + action = request.get_json()['action'] + dataset = Dataset.query.filter_by(id=id).first() + if action == 'delete': success, message = dataset.delete() + elif action == 'default': success, message = dataset.make_default() + if success: return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + +# TODO Test views +# TODO Result views \ No newline at end of file diff --git a/ref-test/app/api/views.py b/ref-test/app/api/views.py index dcbc714..02b7ba0 100644 --- a/ref-test/app/api/views.py +++ b/ref-test/app/api/views.py @@ -1,4 +1,10 @@ -from flask import Blueprint +from ..models import Dataset, Entry +from ..tools.test import evaluate_answers, generate_questions + +from flask import Blueprint, jsonify, request + +from datetime import datetime, timedelta +from json import loads api = Blueprint( name='api', @@ -7,8 +13,54 @@ api = Blueprint( @api.route('/questions/', methods=['POST']) def _fetch_questions(): - return 'Fetch Questions' + id = request.get_json()['id'] + entry = Entry.query.filter_by(id=id).first() + if not entry: return jsonify({'error': 'Invalid entry ID.'}), 400 + test = entry['test'] + user_code = entry['user_code'] + time_limit = test['time_limit'] + time_adjustment = 0 + if time_limit: + _time_limit = int(time_limit) + if user_code: + time_adjustment = test['time_adjustments'][user_code] + _time_limit += time_adjustment + end_delta = timedelta(minutes=_time_limit) + end_time = datetime.utcnow() + end_delta + else: + end_time = None + entry.start() + dataset = test['dataset'] + success, message = dataset.check_file() + if not success: return jsonify({'error': message}), 500 + data_path = dataset.get_file() + with open(data_path, 'r') as data_file: + data = loads(data_file.read()) + questions = generate_questions(data) + return jsonify({ + 'time_limit': end_time, + 'questions': questions, + 'start_time': entry['start_time'], + 'time_adjustment': time_adjustment + }), 200 @api.route('/submit/', methods=['POST']) def _submit_quiz(): - return 'Submit Quiz' + id = request.get_json()['id'] + answers = request.get_json()['answers'] + entry = Entry.query.filter_by(id=id).first() + if not entry: return jsonify({'error': 'Unrecognised ID.'}), 400 + test = entry['test'] + dataset = test['dataset'] + success, message = dataset.check_file() + if not success: return jsonify({'error': message}), 500 + data_path = dataset.get_file() + with open(data_path, 'r') as data_file: + data = loads(data_file.read()) + result = evaluate_answers(answers=answers, key=data) + entry.complete(answers=answers, result=result) + return jsonify({ + 'success': 'Your submission has been processed. Redirecting you to receive your results.', + 'id': id + }), 200 + \ No newline at end of file diff --git a/ref-test/app/install.py b/ref-test/app/install.py index 1559571..843259b 100644 --- a/ref-test/app/install.py +++ b/ref-test/app/install.py @@ -11,6 +11,7 @@ from sqlalchemy_utils import database_exists, create_database def install_scripts(): if not path.isdir(f'./{data}'): mkdir(f'./{data}') + if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions') if not path.isfile(f'./{data}/.gitignore'): with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*') if not path.isfile(f'./{data}/config.json'): save({}, 'config.json') diff --git a/ref-test/app/models/__init__.py b/ref-test/app/models/__init__.py index de5c0f5..71273d1 100644 --- a/ref-test/app/models/__init__.py +++ b/ref-test/app/models/__init__.py @@ -1,3 +1,4 @@ from .entry import Entry from .test import Test -from .user import User \ No newline at end of file +from .user import User +from .dataset import Dataset \ No newline at end of file diff --git a/ref-test/app/models/dataset.py b/ref-test/app/models/dataset.py new file mode 100644 index 0000000..b15c0a9 --- /dev/null +++ b/ref-test/app/models/dataset.py @@ -0,0 +1,82 @@ +from ..data import data +from ..modules import db +from ..tools.logs import write + +from flask import flash, jsonify +from flask_login import current_user +from werkzeug.utils import secure_filename + +from datetime import datetime +from json import dump, loads +from os import path, remove +from uuid import uuid4 + +class Dataset(db.Model): + + id = db.Column(db.String(36), primary_key=True) + tests = db.relationship('Test', backref='dataset') + creator_id = db.Column(db.String(36), db.ForeignKey('user.id')) + date = db.Column(db.DateTime, nullable=False) + default = db.Column(db.Boolean, default=False, nullable=True) + + def __repr__(self): + return f' was added.' + + @property + def generate_id(self): raise AttributeError('generate_id is not a readable attribute.') + + generate_id.setter + def generate_id(self): self.id = uuid4.hex() + + def make_default(self): + for dataset in Dataset.query.all(): + dataset.default = False + self.default = True + db.session.commit() + write('system.log', f'Dataset {self.id} set as default by {current_user.get_username()}.') + flash(message='Dataset set as default.', category='success') + return True, f'Dataset set as default.' + + def delete(self): + if self.default: + message = 'Cannot delete the default dataset.' + flash(message, 'error') + return False, jsonify({'error': message}) + if Dataset.query.all().count() == 1: + message = 'Cannot delete the only dataset.' + flash(message, 'error') + return False, jsonify({'error': message}) + write('system.log', f'Dataset {self.id} deleted by {current_user.get_username()}.') + filename = secure_filename('.'.join([self.id,'json'])) + file_path = path.join(data, 'questions', filename) + remove(file_path) + db.session.delete(self) + db.session.commit() + return True, 'Dataset deleted.' + + def create(self, upload, default:bool=False): + self.generate_id() + timestamp = datetime.now() + filename = secure_filename('.'.join([self.id,'json'])) + file_path = path.join(data, 'questions', filename) + upload.stream.seek(0) + questions = loads(upload.read()) + with open(file_path, 'w') as file: + dump(questions, file, indent=2) + self.date = timestamp + self.creator = current_user + if default: self.make_default() + write('system.log', f'New dataset {self.id} added by {current_user.get_username()}.') + db.session.add(self) + db.session.commit() + return True, 'Dataset uploaded.' + + def check_file(self): + filename = secure_filename('.'.join([self.id,'json'])) + file_path = path.join(data, 'questions', filename) + if not path.isfile(file_path): return False, 'Data file is missing.' + + def get_file(self): + filename = secure_filename('.'.join([self.id,'json'])) + file_path = path.join(data, 'questions', filename) + return file_path \ No newline at end of file diff --git a/ref-test/app/models/entry.py b/ref-test/app/models/entry.py index 0be0046..bc421a2 100644 --- a/ref-test/app/models/entry.py +++ b/ref-test/app/models/entry.py @@ -8,6 +8,7 @@ from flask import jsonify from flask_login import current_user from datetime import datetime, timedelta +from uuid import uuid4 class Entry(db.Model): @@ -25,6 +26,15 @@ class Entry(db.Model): answers = db.Column(JsonEncodedDict, nullable=True) result = db.Column(JsonEncodedDict, nullable=True) + def __repr__(self): + return f' was added with .' + + @property + def generate_id(self): raise AttributeError('generate_id is not a readable attribute.') + + generate_id.setter + def generate_id(self): self.id = uuid4.hex() + @property def set_first_name(self): raise AttributeError('set_first_name is not a readable attribute.') @@ -63,10 +73,11 @@ class Entry(db.Model): write('tests.log', f'New test started by {self.get_first_name()} {self.get_surname()}.') db.session.commit() - def complete(self): + def complete(self, answers:dict=None, result:dict=None): self.end_time = datetime.now() + self.answers = answers write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.') - delta = timedelta(minutes=self.test.time_limit) + delta = timedelta(minutes=self.test.time_limit+1) if not self.test.time_limit or self.end_time <= self.start_time + delta: self.status = 'finished' self.valid = True diff --git a/ref-test/app/models/test.py b/ref-test/app/models/test.py index 9138a94..a9d939f 100644 --- a/ref-test/app/models/test.py +++ b/ref-test/app/models/test.py @@ -11,7 +11,7 @@ from datetime import datetime from json import dump, loads import os import secrets - +from uuid import uuid4 class Test(db.Model): @@ -21,18 +21,33 @@ class Test(db.Model): end_date = db.Column(db.DateTime, nullable=True) time_limit = db.Column(db.Integer, nullable=True) creator_id = db.Column(db.String(36), db.ForeignKey('user.id')) - data = db.Column(db.String(36), nullable=False) + dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id')) adjustments = db.Column(JsonEncodedDict, nullable=True) entries = db.relationship('Entry', backref='test') def __repr__(self): return f'' + @property + def generate_id(self): raise AttributeError('generate_id is not a readable attribute.') + + generate_id.setter + def generate_id(self): self.id = uuid4.hex() + + @property + def generate_code(self): raise AttributeError('generate_code is not a readable attribute.') + + generate_code.setter + def generate_code(self): self.code = secrets.token_hex(6).lower() + def get_code(self): code = self.code.upper() return '—'.join([code[:4], code[4:8], code[8:]]) def create(self): + self.generate_id() + self.generate_code() + self.creator = current_user db.session.add(self) db.session.commit() write('system.log', f'Test with code {self.code} created by {current_user.get_username()}.') diff --git a/ref-test/app/models/user.py b/ref-test/app/models/user.py index f950da3..d8451a5 100644 --- a/ref-test/app/models/user.py +++ b/ref-test/app/models/user.py @@ -17,6 +17,7 @@ class User(UserMixin, db.Model): reset_token = db.Column(db.String(20), nullable=True) verification_token = db.Column(db.String(20), nullable=True) tests = db.relationship('Test', backref='creator') + datasets = db.relationship('Dataset', backref='creator') def __repr__(self): return f' was added with .' @@ -52,6 +53,7 @@ class User(UserMixin, db.Model): def get_email(self): return decrypt(self.email) def register(self, notify:bool=False): + self.generate_id() users = User.query.all() for user in users: if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.' @@ -88,17 +90,19 @@ class User(UserMixin, db.Model): self.reset_token = self.verification_token = None db.session.commit() - def delete(self): + def delete(self, notify:bool=False): username = self.get_username() db.session.delete(self) db.session.commit() - write('users.log', f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.') + message = f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.' + write('users.log', message) + return True, message - def update(self, password:str=None, email:str=None): + def update(self, password:str=None, email:str=None, notify:bool=False): if not password and not email: return False, jsonify({'error': 'There were no changes requested.'}) if password: self.set_password(password) if email: self.set_email(email) db.session.commit() message = f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.' write('system.log', message) - return True, jsonify({'success': message}) + return True, message diff --git a/ref-test/app/templates/privacy.html b/ref-test/app/templates/privacy.html new file mode 100644 index 0000000..013d692 --- /dev/null +++ b/ref-test/app/templates/privacy.html @@ -0,0 +1 @@ +

Privacy Policy

\ No newline at end of file diff --git a/ref-test/app/tools/data.py b/ref-test/app/tools/data.py index 1b68f67..875d283 100644 --- a/ref-test/app/tools/data.py +++ b/ref-test/app/tools/data.py @@ -1,5 +1,7 @@ from ..data import data as data_dir + import json +from random import shuffle def load(filename:str): with open(f'./{data_dir}/{filename}') as file: @@ -7,4 +9,26 @@ def load(filename:str): def save(data:dict, filename:str): with open(f'./{data_dir}/{filename}', 'w') as file: - json.dump(data, file, indent=4) \ No newline at end of file + json.dump(data, file, indent=4) + +def check_is_json(file): + if not '.' in file.filename or not file.filename.rsplit('.',1)[-1] == 'json': return False + return True + +def validate_json(file): + file.stream.seek(0) + data = json.loads(file.read()) + if not type(data) is list: return False + +def randomise_list(list:list): + _list = list.copy() + shuffle(_list) + return(_list) + +def get_tag_list(dataset:list): + output = [] + for block in dataset: + if block['type'] == 'question': output = list(set(output) | set(block['tags'])) + if block['type'] == 'block': + for question in block['questions']: output = list(set(output) | set(question['tags'])) + return output \ No newline at end of file diff --git a/ref-test/app/tools/forms.py b/ref-test/app/tools/forms.py index 98060a3..53e8a39 100644 --- a/ref-test/app/tools/forms.py +++ b/ref-test/app/tools/forms.py @@ -33,4 +33,13 @@ def value(min:int=0, max:int=None): 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 + return length + +def get_time_options(): + time_options = [ + ('none', 'None'), + ('60', '1 hour'), + ('90', '1 hour 30 minutes'), + ('120', '2 hours') + ] + return time_options \ No newline at end of file diff --git a/ref-test/app/tools/test.py b/ref-test/app/tools/test.py index d2cbbd7..469d445 100644 --- a/ref-test/app/tools/test.py +++ b/ref-test/app/tools/test.py @@ -1,2 +1,104 @@ +from .data import randomise_list + def parse_test_code(code): - return code.replace('—', '').lower() \ No newline at end of file + return code.replace('—', '').lower() + +def generate_questions(dataset:list): + output = [] + for block in randomise_list(dataset): + if block['type'] == 'question': + question = { + 'type': 'question', + 'q_no': block['q_no'], + 'question_header': '', + 'text': block['text'] + } + if block['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(block['options'])]) + else: question['options'] = block['options'].copy() + output.append(question) + elif block['type'] == 'block': + for key, _question in enumerate(randomise_list(block['questions'])): + question = { + 'type': 'block', + 'q_no': _question['q_no'], + 'question_header': block['question_header'] if 'question_header' in block else '', + 'block_length': len(block['questions']), + 'block_q_no': key, + 'text': _question['text'] + } + if _question['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(_question['options'])]) + else: question['options'] = _question['options'].copy() + output.append(question) + return output + +def evaluate_answers(answers:dict, key:list): + score = 0 + max = 0 + tags = {} + for block in key: + if block['type'] == 'question': + max += 1 + q_no = block['q_no'] + if str(q_no) in answers: + submitted_answer = int(answers[str(q_no)]) + if submitted_answer == block['correct']: + score += 1 + for tag in block['tags']: + if tag not in tags: + tags[tag] = { + 'scored': 1, + 'max': 1 + } + else: + tags[tag]['scored'] += 1 + tags[tag]['max'] += 1 + else: + for tag in block['tags']: + if tag not in tags: + tags[tag] = { + 'scored': 0, + 'max': 1 + } + else: tags[tag]['max'] += 1 + elif block['type'] == 'block': + for question in block['questions']: + max += 1 + q_no = question['q_no'] + if str(q_no) in answers: + submitted_answer = int(answers[str(q_no)]) + if submitted_answer == question['correct']: + score += 1 + for tag in question['tags']: + if tag not in tags: + tags[tag] = { + 'scored': 1, + 'max': 1 + } + else: + tags[tag]['scored'] += 1 + tags[tag]['max'] += 1 + else: + for tag in question['tags']: + if tag not in tags: + tags[tag] = { + 'scored': 0, + 'max': 1 + } + else: tags[tag]['max'] += 1 + grade = 'merit' if score/max >= .85 else 'pass' if score/max >= .70 else 'fail' + return { + 'grade': grade, + 'tags': tags, + 'score': score, + 'max': max + } + +def get_correct_answers(dataset:list): + output = {} + for block in dataset: + if block['type'] == 'question': + output[str(block['q_no'])] = block['options'][block['correct']] + if block['type'] == 'block': + for question in block['questions']: + output[str(question['q_no'])] = question['options'][question['correct']] + return output \ No newline at end of file diff --git a/ref-test/app/views.py b/ref-test/app/views.py new file mode 100644 index 0000000..ef48434 --- /dev/null +++ b/ref-test/app/views.py @@ -0,0 +1,12 @@ +from flask import Blueprint, render_template + +views = Blueprint( + name='common', + import_name=__name__, + template_folder='templates', + static_folder='static' +) + +@views.route('/privacy/') +def _privacy(): + return render_template('privacy.html') \ No newline at end of file diff --git a/ref-test/app/views/__init__.py b/ref-test/app/views/__init__.py deleted file mode 100644 index e645226..0000000 --- a/ref-test/app/views/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from flask import Blueprint - -views = Blueprint( - name='common', - import_name=__name__, - template_folder='templates', - static_folder='static' -) - -from . import privacy \ No newline at end of file diff --git a/ref-test/app/views/privacy.py b/ref-test/app/views/privacy.py deleted file mode 100644 index 6e33bf5..0000000 --- a/ref-test/app/views/privacy.py +++ /dev/null @@ -1,5 +0,0 @@ -from . import views - -@views.route('/privacy/') -def _privacy(): - return 'Privacy Policy' \ No newline at end of file