diff --git a/ref-test/app/admin/views.py b/ref-test/app/admin/views.py index ace2faa..978cede 100644 --- a/ref-test/app/admin/views.py +++ b/ref-test/app/admin/views.py @@ -1,12 +1,16 @@ -from ..forms.admin import CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData -from ..models import Dataset, User +from ..forms.admin import AddTimeAdjustment, CreateTest, CreateUser, DeleteUser, Login, Register, ResetPassword, UpdatePassword, UpdateUser, UploadData +from ..models import Dataset, Entry, Test, User from ..tools.auth import disable_if_logged_in, require_account_creation +from ..tools.forms import get_dataset_choices, get_time_options from ..tools.data import check_is_json, validate_json +from ..tools.test import get_correct_answers -from flask import Blueprint, flash, jsonify, render_template, redirect, request, session -from flask.helpers import url_for +from flask import Blueprint, jsonify, render_template, redirect, request, session +from flask.helpers import flash, url_for from flask_login import current_user, login_required +from datetime import date, datetime +from json import loads import secrets admin = Blueprint( @@ -21,7 +25,17 @@ admin = Blueprint( @admin.route('/dashboard/') @login_required def _home(): - return 'Home Page' # TODO Dashboard + tests = Test.query.all() + results = Entry.query.all() + current_tests = [ test for test in tests if test['expiry_date'].date() >= datetime.now().date() and test['start_date'].date() <= date.today() ] + current_tests.sort(key= lambda x: x['expiry_date'], reverse=True) + upcoming_tests = [ test for test in tests if test['start_date'].date() > datetime.now().date()] + upcoming_tests.sort(key= lambda x: x['start_date']) + recent_results = [result for result in results if not result['status'] == 'started' ] + recent_results.sort(key= lambda x: x['end_time'], reverse=True) + for result in recent_results: + result['percent'] = round(100*result['result']['score']/result['result']['max']) + return render_template('/admin/index.html', current_tests = current_tests, upcomimg_tests = upcoming_tests, recent_results = recent_results) @admin.route('/settings/') @login_required @@ -216,14 +230,159 @@ def _quesitons(): @admin.route('/settings/questions/edit/', methods=['POST']) @login_required -def _delete_questions(): +def _edit_questions(): id = request.get_json()['id'] action = request.get_json()['action'] + if action not in ['defailt', 'delete']: return jsonify({'error': 'Invalid action.'}), 400 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 +@admin.route('/tests//', methods=['GET']) +@admin.route('/tests/', methods=['GET']) +@login_required +def _tests(filter:str=None): + datasets = Dataset.query.all() + tests = None + _tests = Test.query.all() + form = None + if not datasets: + flash('There are no available question datasets. Please upload a question dataset in order to set up an exam.', 'error') + return redirect(url_for('admin._questions')) + if filter not in [None, '', 'create','active','scheduled','expired','all']: return redirect(url_for('admin._tests')) + if filter == 'create': + form = CreateTest() + form.time_limit.choices = get_time_options() + form.dataset.choices = get_dataset_choices + form.time_limit.default='none' + form.process() + display_title = '' + error_none = '' + if filter in [None, '', 'active']: + tests = [ test for test in _tests if test['expiry_date'].date() >= date.today() and test['start_date'].date() <= date.today() ] + display_title = 'Active Exams' + error_none = 'There are no exams that are currently active. You can create one using the Creat Exam form.' + if filter == 'expired': + tests = [ test for test in _tests if test['expiry_date'].date() < date.today() ] + display_title = 'Expired Exams' + error_none = 'There are no expired exams. Exams will appear in this category after their expiration date has passed.' + if filter == 'scheduled': + tests = [ test for test in _tests if test['start_date'].date() > date.today()] + display_title = 'Scheduled Exams' + error_none = 'There are no scheduled exams pending. You can schedule an exam for the future using the Create Exam form.' + if filter == 'all': + tests = _tests + display_title = 'All Exams' + error_none = 'There are no exams set up. You can create one using the Create Exam form.' + return render_template('/admin/tests.html', form = form, tests=tests, display_title=display_title, error_none=error_none, filter=filter) + +@admin.route('/tests/create/', methods=['POST']) +@login_required +def _create_test(): + form = CreateTest() + form.dataset.choices = get_dataset_choices() + form.time_limit.choices = get_time_options() + if form.validate_on_submit(): + new_test = Test() + new_test.start_date = request.form.get('start_date') + new_test.start_date = datetime.strptime(new_test.start_date, '%Y-%m-%d') + new_test.end_date = request.form.get('expiry_date') + new_test.end_date = datetime.strptime(new_test.end_date, '%Y-%m-%d') + dataset = request.form.get('dataset') + new_test.dataset = Dataset.query.filter_by(id=dataset) + success, message = new_test.create() + if success: + flash(message=message, category='success') + return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + else: + errors = [*form.start_date.errors, *form.expiry_date.errors, *form.time_limit.errors] + return jsonify({ 'error': errors}), 400 + +@admin.route('/tests/edit/', methods=['POST']) +@login_required +def _edit_test(): + id = request.get_json()['id'] + action = request.get_json()['action'] + if action not in ['start', 'delete', 'end']: return jsonify({'error': 'Invalid action.'}), 400 + test = Test.query.filter_by(id=id).first() + if not test: return jsonify({'error': 'Could not find the corresponding test to delete.'}), 404 + if action == 'delete': success, message = test.delete() + if action == 'start': success, message = test.start() + if action == 'end': success, message = test.end() + if success: + flash(message=message, category='success') + return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + +@admin.route('/test//', methods=['GET','POST']) +@login_required +def _view_test(id:str=None): + form = AddTimeAdjustment() + test = Test.query.filter_by(id=id).first() + if request.method == 'POST': + if not test: return jsonify({'error': 'Invalid test ID.'}), 404 + if form.validate_on_submit(): + time = int(request.form.get('time')) + success, message = test.add_adjustment(time) + if success: return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + return jsonify({'error': form.time.errors }), 400 + if not test: + flash('Invalid test ID.', 'error') + return redirect(url_for('admin._tests')) + return render_template('/admin/test.html', test = test, form = form) + +@admin.route('/test//delete-adjustment/', methods=['POST']) +@login_required +def _delete_adjustment(id:str=None): + test = Test.query.filter_by(id=id).first() + if not test: return jsonify({'error': 'Invalid test ID.'}), 404 + user_code = request.get_json()['user_code'].lower() + success, message = test.remove_adjustment(user_code) + if success: return jsonify({'success': message}), 200 + return jsonify({'error': message}), 400 + +@admin.route('/results/') +@login_required +def _view_entries(): + entries = Entry.query.all() + return render_template('/admin/results.html', entries = entries) + +@admin.route('/results//', methods = ['GET', 'POST']) +@login_required +def _view_entry(id:str=None): + entry = Entry.query.filter_by(id=id) + if request.method == 'POST': + if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404 + action = request.get_json()['action'] + if action not in ['validate', 'delete']: return jsonify({'error': 'Invalid action.'}), 400 + if action == 'validate': + success, message = entry.validate() + if action == 'delete': + success, message = entry.delete() + if success: + flash(message, 'success') + return jsonify({'success': message}), 200 + return jsonify({'error': message}),400 + if not entry: + flash('Invalid entry ID.', 'error') + return redirect(url_for('admin._view_entries')) + test = entry['test'] + dataset = test['dataset'] + dataset_path = dataset.get_file() + with open(dataset_path, 'r') as _dataset: + data = loads(_dataset.read()) + correct = get_correct_answers(dataset=data) + return render_template('/admin/result-detail.html', entry = entry, correct = correct) + +@admin.route('/certificate/',methods=['POST']) +@login_required +def _generate_certificate(): + from main import db + id = request.get_json()['id'] + entry = Entry.query.filter_by(id=id).first() + if not entry: return jsonify({'error': 'Invalid entry ID.'}), 404 + return render_template('/admin/components/certificate.html', entry = entry) \ No newline at end of file diff --git a/ref-test/app/models/dataset.py b/ref-test/app/models/dataset.py index b15c0a9..692e4db 100644 --- a/ref-test/app/models/dataset.py +++ b/ref-test/app/models/dataset.py @@ -2,7 +2,7 @@ from ..data import data from ..modules import db from ..tools.logs import write -from flask import flash, jsonify +from flask import flash from flask_login import current_user from werkzeug.utils import secure_filename @@ -41,11 +41,11 @@ class Dataset(db.Model): if self.default: message = 'Cannot delete the default dataset.' flash(message, 'error') - return False, jsonify({'error': message}) + return False, message if Dataset.query.all().count() == 1: message = 'Cannot delete the only dataset.' flash(message, 'error') - return False, jsonify({'error': message}) + return False, 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) diff --git a/ref-test/app/models/entry.py b/ref-test/app/models/entry.py index bc421a2..4a9ffcb 100644 --- a/ref-test/app/models/entry.py +++ b/ref-test/app/models/entry.py @@ -4,7 +4,6 @@ from ..tools.encryption import decrypt, encrypt from ..tools.logs import write from .test import Test -from flask import jsonify from flask_login import current_user from datetime import datetime, timedelta @@ -79,7 +78,7 @@ class Entry(db.Model): write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.') 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.status = 'completed' self.valid = True else: self.status = 'late' @@ -87,10 +86,18 @@ class Entry(db.Model): db.session.commit() def validate(self): - if self.valid: return False, jsonify({'error':f'The entry is already valid.'}) - if self.status == 'started': return False, jsonify({'error':f'The entry is still pending.'}) + if self.valid: return False, f'The entry is already valid.' + if self.status == 'started': return False, 'The entry is still pending.' self.valid = True self.status = 'completed' db.session.commit() - message = f'The entry {self.id} has been validated by {current_user.get_username()}.' - return True, jsonify({'success': message}) + write('system.log', f'The entry {self.id} has been validated by {current_user.get_username()}.') + return True, f'The entry {self.id} has been validated.' + + def delete(self): + id = self.id + name = f'{self.get_first_name()} {self.get_surname()}' + db.session.delete(self) + db.session.commit() + write('system.log', f'The entry {id} by {name} has been deleted by {current_user.get_username()}.') + return True, 'Entry deleted.' \ No newline at end of file diff --git a/ref-test/app/models/test.py b/ref-test/app/models/test.py index a9d939f..c41456a 100644 --- a/ref-test/app/models/test.py +++ b/ref-test/app/models/test.py @@ -3,11 +3,9 @@ from ..tools.encryption import decrypt, encrypt from ..tools.forms import JsonEncodedDict from ..tools.logs import write -from flask import jsonify -from flask.helpers import flash from flask_login import current_user -from datetime import datetime +from datetime import date, datetime from json import dump, loads import os import secrets @@ -48,35 +46,45 @@ class Test(db.Model): self.generate_id() self.generate_code() self.creator = current_user + errors = [] + if self.start_date.date() < date.today(): + errors.append('The start date cannot be in the past.') + if self.end_date.date() < date.today(): + errors.append('The expiry date cannot be in the past.') + if self.end_date < self.start_date: + errors.append('The expiry date cannot be before the start date.') + if errors: + return False, errors db.session.add(self) 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.get_code()} created by {current_user.get_username()}.') + return True, f'Test with code {self.get_code()} has been created.' def delete(self): code = self.code + if self.entries: return False, f'Cannot delete a test with submitted entries.' db.session.delete(self) db.session.commit() - write('system.log', f'Test with code {code} deleted by {current_user.get_username()}.') + write('system.log', f'Test with code {code} has been deleted by {current_user.get_username()}.') + return True, f'Test with code {code} has been deleted.' def start(self): now = datetime.now() - if self.start_date > now: + if self.start_date.date() > now.date(): self.start_date = now db.session.commit() - message = f'Test with code {self.code} started by {current_user.get_username()}.' - write('system.log', message) - return True, jsonify({'success': message}) - return False, jsonify({'error': f'Test with code {self.code} has already started.'}) + write('system.log', f'Test with code {self.code} has been started by {current_user.get_username()}.') + return True, f'Test with code {self.code} has been started.' + return False, f'Test with code {self.code} has already started.' def end(self): now = datetime.now() - if self.end_date > now: + if self.end_date.date() > now.date(): self.end_date = now db.session.commit() - message = f'Test with code {self.code} ended by {current_user.get_username()}.' - write('system.log', message) - return True, jsonify({'success': message}) - return False, jsonify({'error': f'Test with code {self.code} has already started.'}) + write('system.log', f'Test with code {self.code} ended by {current_user.get_username()}.') + return True, f'Test with code {self.code} has been ended.' + return False, f'Test with code {self.code} has already ended.' def add_adjustment(self, time:int): adjustments = self.adjustments if self.adjustments is not None else {} @@ -85,23 +93,21 @@ class Test(db.Model): self.adjustments = adjustments db.session.commit() write('system.log', f'Time adjustment for {time} minutes with code {code} added to test {self.get_code()} by {current_user.get_username()}.') - return True, jsonify({'success': f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.'}) + return True, f'Time adjustment for {time} minutes added to test {self.get_code()}. This can be accessed using the user code {code.upper()}.' def remove_adjustment(self, code:str): - if not self.adjustments: return False, jsonify({'error': f'There are no adjustments configured for test {self.get_code()}.'}) + if not self.adjustments: return False, f'There are no adjustments configured for test {self.get_code()}.' self.adjustments.pop(code) if not self.adjustments: self.adjustments = None db.session.commit() - message = f'Time adjustment for with code {code} removed from test {self.get_code()} by {current_user.get_username()}.' - write('system.log', message) - return True, jsonify({'success': message}) + write('system.log', f'Time adjustment for with code {code} has been removed from test {self.get_code()} by {current_user.get_username()}.') + return True, f'Time adjustment for with code {code} has been removed from test {self.get_code()}.' def update(self, start_date:datetime=None, end_date:datetime=None, time_limit:int=None): - if not start_date and not end_date and time_limit is None: return False, jsonify({'error': 'There were no changes requested.'}) + if not start_date and not end_date and time_limit is None: return False, 'There were no changes requested.' if start_date: self.start_date = start_date if end_date: self.end_date = end_date if time_limit is not None: self.time_limit = time_limit db.session.commit() - message = f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}' - write('system.log', message) - return True, jsonify({'success': message}) \ No newline at end of file + write('system.log', f'Test with code {self.get_code()} has been updated by user {current_user.get_username()}.') + return True, f'Test with code {self.get_code()} has been updated by.' \ No newline at end of file diff --git a/ref-test/app/models/user.py b/ref-test/app/models/user.py index d8451a5..d05e796 100644 --- a/ref-test/app/models/user.py +++ b/ref-test/app/models/user.py @@ -99,7 +99,7 @@ class User(UserMixin, db.Model): return True, message 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, 'There were no changes requested.' if password: self.set_password(password) if email: self.set_email(email) db.session.commit() diff --git a/ref-test/app/tools/forms.py b/ref-test/app/tools/forms.py index 53e8a39..82f2689 100644 --- a/ref-test/app/tools/forms.py +++ b/ref-test/app/tools/forms.py @@ -1,4 +1,5 @@ +from ..models import Dataset from ..modules import db from wtforms.validators import ValidationError @@ -42,4 +43,14 @@ def get_time_options(): ('90', '1 hour 30 minutes'), ('120', '2 hours') ] - return time_options \ No newline at end of file + return time_options + +def get_dataset_choices(): + datasets = Dataset.query.all() + dataset_choices = [] + for dataset in datasets: + label = dataset['date'].strftime('%Y%m%d%H%M%S') + label = f'{label} (Default)' if dataset.default else label + choice = (dataset['id'], label) + dataset_choices.append(choice) + return dataset_choices \ No newline at end of file