Added a whole lot of views.

Finished quiz API views
Finished question generator and answer eval
This commit is contained in:
Vivek Santayana 2022-06-14 22:55:11 +01:00
parent a58f267586
commit 126bf9203c
15 changed files with 421 additions and 35 deletions

View File

@ -1,6 +1,7 @@
from ..forms.admin import CreateUser, Login, Register, ResetPassword, UpdatePassword from ..forms.admin import CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData
from ..models import User from ..models import Dataset, User
from ..tools.auth import disable_if_logged_in, require_account_creation from ..tools.auth import disable_if_logged_in, require_account_creation
from ..tools.data import check_is_json, validate_json
from flask import Blueprint, flash, jsonify, render_template, redirect, request, session from flask import Blueprint, flash, jsonify, render_template, redirect, request, session
from flask.helpers import url_for from flask.helpers import url_for
@ -18,12 +19,16 @@ admin = Blueprint(
@admin.route('/') @admin.route('/')
@admin.route('/home/') @admin.route('/home/')
@admin.route('/dashboard/') @admin.route('/dashboard/')
@login_required
def _home(): def _home():
return 'Home Page' return 'Home Page' # TODO Dashboard
@admin.route('/settings/') @admin.route('/settings/')
@login_required
def _settings(): 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']) @admin.route('/login/', methods=['GET','POST'])
@disable_if_logged_in @disable_if_logged_in
@ -64,7 +69,6 @@ def _register():
if request.method == 'POST': if request.method == 'POST':
if form.validate_on_submit(): if form.validate_on_submit():
new_user = User() new_user = User()
new_user.generate_id()
new_user.set_username = request.form.get('username').lower() new_user.set_username = request.form.get('username').lower()
new_user.set_email = request.form.get('email').lower() new_user.set_email = request.form.get('email').lower()
new_user.set_password = request.form.get('password').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] errors = [*form.username.errors, *form.email.errors, *form.password.errors]
return jsonify({ 'error': errors}), 401 return jsonify({ 'error': errors}), 401
return render_template('/admin/settings/users.html', form = form, users = users) return render_template('/admin/settings/users.html', form = form, users = users)
@admin.route('/settings/users/delete/<string:id>', methods=['GET', 'POST'])
@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/<string:id>', 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

View File

@ -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( api = Blueprint(
name='api', name='api',
@ -7,8 +13,54 @@ api = Blueprint(
@api.route('/questions/', methods=['POST']) @api.route('/questions/', methods=['POST'])
def _fetch_questions(): 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']) @api.route('/submit/', methods=['POST'])
def _submit_quiz(): 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

View File

@ -11,6 +11,7 @@ from sqlalchemy_utils import database_exists, create_database
def install_scripts(): def install_scripts():
if not path.isdir(f'./{data}'): mkdir(f'./{data}') if not path.isdir(f'./{data}'): mkdir(f'./{data}')
if not path.isdir(f'./{data}/questions'): mkdir(f'./{data}/questions')
if not path.isfile(f'./{data}/.gitignore'): if not path.isfile(f'./{data}/.gitignore'):
with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*') with open(f'./{data}/.gitignore', 'a+') as file: file.write(f'*')
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json') if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')

View File

@ -1,3 +1,4 @@
from .entry import Entry from .entry import Entry
from .test import Test from .test import Test
from .user import User from .user import User
from .dataset import Dataset

View File

@ -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'<Dataset {self.id}> 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

View File

@ -8,6 +8,7 @@ from flask import jsonify
from flask_login import current_user from flask_login import current_user
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import uuid4
class Entry(db.Model): class Entry(db.Model):
@ -25,6 +26,15 @@ class Entry(db.Model):
answers = db.Column(JsonEncodedDict, nullable=True) answers = db.Column(JsonEncodedDict, nullable=True)
result = db.Column(JsonEncodedDict, nullable=True) result = db.Column(JsonEncodedDict, nullable=True)
def __repr__(self):
return f'<New entry by {self.first_name} {self.surname}> was added with <id {self.id}>.'
@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 @property
def set_first_name(self): raise AttributeError('set_first_name is not a readable attribute.') 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()}.') write('tests.log', f'New test started by {self.get_first_name()} {self.get_surname()}.')
db.session.commit() db.session.commit()
def complete(self): def complete(self, answers:dict=None, result:dict=None):
self.end_time = datetime.now() self.end_time = datetime.now()
self.answers = answers
write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.') 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: if not self.test.time_limit or self.end_time <= self.start_time + delta:
self.status = 'finished' self.status = 'finished'
self.valid = True self.valid = True

View File

@ -11,7 +11,7 @@ from datetime import datetime
from json import dump, loads from json import dump, loads
import os import os
import secrets import secrets
from uuid import uuid4
class Test(db.Model): class Test(db.Model):
@ -21,18 +21,33 @@ class Test(db.Model):
end_date = db.Column(db.DateTime, nullable=True) end_date = db.Column(db.DateTime, nullable=True)
time_limit = db.Column(db.Integer, nullable=True) time_limit = db.Column(db.Integer, nullable=True)
creator_id = db.Column(db.String(36), db.ForeignKey('user.id')) 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) adjustments = db.Column(JsonEncodedDict, nullable=True)
entries = db.relationship('Entry', backref='test') entries = db.relationship('Entry', backref='test')
def __repr__(self): def __repr__(self):
return f'<test with code {self.code} was created by {current_user.get_username()}.>' return f'<test with code {self.code} was created by {current_user.get_username()}.>'
@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): def get_code(self):
code = self.code.upper() code = self.code.upper()
return ''.join([code[:4], code[4:8], code[8:]]) return ''.join([code[:4], code[4:8], code[8:]])
def create(self): def create(self):
self.generate_id()
self.generate_code()
self.creator = current_user
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
write('system.log', f'Test with code {self.code} created by {current_user.get_username()}.') write('system.log', f'Test with code {self.code} created by {current_user.get_username()}.')

View File

@ -17,6 +17,7 @@ class User(UserMixin, db.Model):
reset_token = db.Column(db.String(20), nullable=True) reset_token = db.Column(db.String(20), nullable=True)
verification_token = db.Column(db.String(20), nullable=True) verification_token = db.Column(db.String(20), nullable=True)
tests = db.relationship('Test', backref='creator') tests = db.relationship('Test', backref='creator')
datasets = db.relationship('Dataset', backref='creator')
def __repr__(self): def __repr__(self):
return f'<user {self.username}> was added with <id {self.id}>.' return f'<user {self.username}> was added with <id {self.id}>.'
@ -52,6 +53,7 @@ class User(UserMixin, db.Model):
def get_email(self): return decrypt(self.email) def get_email(self): return decrypt(self.email)
def register(self, notify:bool=False): def register(self, notify:bool=False):
self.generate_id()
users = User.query.all() users = User.query.all()
for user in users: for user in users:
if user.get_username() == self.get_username(): return False, f'Username {self.get_username()} already in use.' 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 self.reset_token = self.verification_token = None
db.session.commit() db.session.commit()
def delete(self): def delete(self, notify:bool=False):
username = self.get_username() username = self.get_username()
db.session.delete(self) db.session.delete(self)
db.session.commit() 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 not password and not email: return False, jsonify({'error': 'There were no changes requested.'})
if password: self.set_password(password) if password: self.set_password(password)
if email: self.set_email(email) if email: self.set_email(email)
db.session.commit() db.session.commit()
message = f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.' message = f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.'
write('system.log', message) write('system.log', message)
return True, jsonify({'success': message}) return True, message

View File

@ -0,0 +1 @@
<h1>Privacy Policy</h1>

View File

@ -1,5 +1,7 @@
from ..data import data as data_dir from ..data import data as data_dir
import json import json
from random import shuffle
def load(filename:str): def load(filename:str):
with open(f'./{data_dir}/{filename}') as file: with open(f'./{data_dir}/{filename}') as file:
@ -8,3 +10,25 @@ def load(filename:str):
def save(data:dict, filename:str): def save(data:dict, filename:str):
with open(f'./{data_dir}/{filename}', 'w') as file: with open(f'./{data_dir}/{filename}', 'w') as file:
json.dump(data, file, indent=4) json.dump(data, file, indent=4)
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

View File

@ -34,3 +34,12 @@ def value(min:int=0, max:int=None):
if value < min or max != None and value > max: if value < min or max != None and value > max:
raise ValidationError(message) raise ValidationError(message)
return length return length
def get_time_options():
time_options = [
('none', 'None'),
('60', '1 hour'),
('90', '1 hour 30 minutes'),
('120', '2 hours')
]
return time_options

View File

@ -1,2 +1,104 @@
from .data import randomise_list
def parse_test_code(code): def parse_test_code(code):
return code.replace('', '').lower() 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

12
ref-test/app/views.py Normal file
View File

@ -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')

View File

@ -1,10 +0,0 @@
from flask import Blueprint
views = Blueprint(
name='common',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
from . import privacy

View File

@ -1,5 +0,0 @@
from . import views
@views.route('/privacy/')
def _privacy():
return 'Privacy Policy'