diff --git a/ref-test/app/admin/templates/admin/auth/register.html b/ref-test/app/admin/templates/admin/auth/register.html new file mode 100644 index 0000000..11a0d83 --- /dev/null +++ b/ref-test/app/admin/templates/admin/auth/register.html @@ -0,0 +1 @@ +

Register

\ No newline at end of file diff --git a/ref-test/app/forms/admin.py b/ref-test/app/forms/admin.py new file mode 100644 index 0000000..d417945 --- /dev/null +++ b/ref-test/app/forms/admin.py @@ -0,0 +1,62 @@ +from ..tools.forms import value + +from flask_wtf import FlaskForm +from flask_wtf.file import FileAllowed, FileField, FileRequired +from wtforms import BooleanField, DateField, IntegerField, PasswordField, SelectField, StringField +from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional + +from datetime import date, timedelta + +class Login(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.')]) + remember = BooleanField('Remember Log In', render_kw={'checked': True}) + +class Register(FlaskForm): + username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) + email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')]) + +class ResetPassword(FlaskForm): + username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) + email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)]) + +class UpdatePassword(FlaskForm): + password = PasswordField('Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.'), EqualTo('password', message='Passwords do not match.')]) + +class CreateUser(FlaskForm): + username = StringField('Username', validators=[InputRequired(), Length(min=4, max=15)]) + email = StringField('Email Address', validators=[InputRequired(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Password (Optional)', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + +class DeleteUser(FlaskForm): + password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + notify = BooleanField('Notify deletion by email', render_kw={'checked': True}) + +class UpdateUser(FlaskForm): + confirm_password = PasswordField('Confirm Your Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')]) + notify = BooleanField('Notify changes by email', render_kw={'checked': True}) + +class UpdateAccount(FlaskForm): + confirm_password = PasswordField('Current Password', validators=[InputRequired(), Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + email = StringField('Email Address', validators=[Optional(), Email(message='You must enter a valid email address.'), Length(max=50)]) + password = PasswordField('Change Password', validators=[Optional(),Length(min=6, max=30, message='The password must be between 6 and 20 characters long.')]) + password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')]) + +class CreateTest(FlaskForm): + start_date = DateField('Start Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() ) + expiry_date = DateField('Expiry Date', format="%Y-%m-%d", validators=[InputRequired()], default = date.today() + timedelta(days=1) ) + time_limit = SelectField('Time Limit') + dataset = SelectField('Question Dataset') + +class UploadData(FlaskForm): + data_file = FileField('Data File', validators=[FileRequired(), FileAllowed(['json'])]) + 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/app/forms/quiz.py b/ref-test/app/forms/quiz.py new file mode 100644 index 0000000..e69de29 diff --git a/ref-test/app/models/__init__.py b/ref-test/app/models/__init__.py index e69de29..de5c0f5 100644 --- a/ref-test/app/models/__init__.py +++ b/ref-test/app/models/__init__.py @@ -0,0 +1,3 @@ +from .entry import Entry +from .test import Test +from .user import User \ No newline at end of file diff --git a/ref-test/app/models/entry.py b/ref-test/app/models/entry.py index 5bcab96..28b5e70 100644 --- a/ref-test/app/models/entry.py +++ b/ref-test/app/models/entry.py @@ -1,4 +1,13 @@ -from main import db +from ..modules import db +from ..tools.forms import JsonEncodedDict +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 class Entry(db.Model): @@ -8,13 +17,14 @@ class Entry(db.Model): email = db.Column(db.String(128), nullable=False) club = db.Column(db.String(128), nullable=True) test_id = db.Column(db.String(36), db.ForeignKey('test.id')) - test_code = db.Column(db.String(36), db.ForeignKey('test.test_code')) + test_code = db.Column(db.String(36), db.ForeignKey('test.code')) user_code = db.Column(db.String(6), nullable=True) start_time = db.Column(db.DateTime, nullable=False) end_time = db.Column(db.DateTime, nullable=True) status = db.Column(db.String(16), nullable=True) - late_ignore = db.Column(db.Boolean, default=False, nullable=True) + valid = db.Column(db.Boolean, default=True, nullable=True) answers = db.Column(JsonEncodedDict, nullable=True) + result = db.Column(JsonEncodedDict, nullable=True) @property def set_first_name(self): raise AttributeError('set_first_name is not a readable attribute.') @@ -35,7 +45,7 @@ class Entry(db.Model): @property def set_email(self): raise AttributeError('set_email is not a readable attribute.') - set_name.setter + set_email.setter def set_email(self, email:str): self.email = encrypt(email) def get_email(self): return decrypt(self.email) @@ -43,7 +53,35 @@ class Entry(db.Model): @property def set_club(self): raise AttributeError('set_club is not a readable attribute.') - set_name.setter + set_club.setter def set_club(self, club:str): self.club = encrypt(club) - def get_club(self): return decrypt(self.club) \ No newline at end of file + def get_club(self): return decrypt(self.club) + + def start(self): + self.start_time = datetime.now() + self.status = 'started' + write('tests.log', f'New test started by {self.get_first_name()} {self.get_surname()}.') + db.session.commit() + + def complete(self): + self.end_time = datetime.now() + write('tests.log', f'Test completed by {self.get_first_name()} {self.get_surname()}.') + test = Test.query.filter_by(code=self.test_code).first() + delta = timedelta(minutes=test.time_limit) + if not test.time_limit or self.end_time <= self.start_time + delta: + self.status = 'finished' + self.valid = True + else: + self.status = 'late' + self.valid = False + 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.'}) + 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}) diff --git a/ref-test/app/models/test.py b/ref-test/app/models/test.py index 8ab15b1..cd28e79 100644 --- a/ref-test/app/models/test.py +++ b/ref-test/app/models/test.py @@ -1,112 +1,91 @@ -class Test: - - 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 == '' or time_limit == None else int(time_limit) - self.creator = creator - self.dataset = dataset +from ..modules import db +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 json import dump, loads +import os +import secrets + + +class Test(db.Model): + id = db.Column(db.String(36), primary_key=True) + code = db.Column(db.String(36), nullable=False) + start_date = db.Column(db.DateTime, nullable=True) + end_date = db.Column(db.DateTime, nullable=True) + time_limit = db.Column(db.Integer, nullable=True) + creator = db.Column(db.String(36), db.ForeignKey('user.id')) + data = db.Column(db.String(36), nullable=False) + adjustments = db.Column(JsonEncodedDict, nullable=True) + + def __repr__(self): + return f'' + + def get_code(self): + code = self.code.upper() + return '—'.join([code[:4], code[4:8], code[8:]]) + def create(self): - from main import app, db - test = { - '_id': self._id, - 'date_created': datetime.today(), - 'start_date': self.start_date, - 'expiry_date': self.expiry_date, - 'time_limit': self.time_limit, - 'creator': encrypt(self.creator), - '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 - - def add_time_adjustment(self, time_adjustment): - from main import db - user_code = secrets.token_hex(3).upper() - adjustment = { - user_code: time_adjustment - } - if db.tests.find_one_and_update({'_id': self._id}, {'$set': {'time_adjustments': adjustment}},upsert=False): - flash(f'Time adjustment for {time_adjustment} minutes has been added. This can be enabled using the user code {user_code}.') - return jsonify({'success': adjustment}) - return jsonify({'error': 'Failed to add the time adjustment. An error occurred.'}), 400 - - def remove_time_adjustment(self, user_code): - from main import db - if db.tests.find_one_and_update({'_id': self._id}, {'$unset': {f'time_adjustments.{user_code}': {}}}): - message = 'Time adjustment has been deleted.' - flash(message, 'success') - return jsonify({'success': message}) - return jsonify({'error': 'Failed to delete the time adjustment. An error occurred.'}), 400 - - def render_test_code(self, test_code): - return '—'.join([test_code[:4], test_code[4:8], test_code[8:]]) - - def parse_test_code(self, test_code): - return test_code.replace('—', '') + db.session.add(self) + db.session.commit() + write('system.log', f'Test with code {self.code} created by {current_user.get_username()}.') def delete(self): - from main import app, db - test = db.tests.find_one({'_id': self._id}) - if 'entries' in test: - if test['entries']: - return jsonify({'error': 'Cannot delete an exam that has entries submitted to it.'}), 400 - 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 - return jsonify({'error': f'Could not create exam. An error occurred.'}), 400 + code = self.code + db.session.delete(self) + db.session.commit() + write('system.log', f'Test with code {code} deleted by {current_user.get_username()}.') + + def start(self): + now = datetime.now() + if self.start_date > now: + 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.'}) - def update(self): - from main import db - test = {} - updated = [] - if not self.start_date == '' and self.start_date is not None: - test['start_date'] = self.start_date - updated.append('start date') - if not self.expiry_date == '' and self.expiry_date is not None: - test['expiry_date'] = self.expiry_date - updated.append('expiry date') - if not self.time_limit == '' and self.time_limit is not None: - test['time_limit'] = int(self.time_limit) - updated.append('time limit') - output = '' - if len(updated) == 0: - flash(f'There were no changes requested for your account.', 'alert'), 200 - return jsonify({'success': 'There were no changes requested for your account.'}), 200 - elif len(updated) == 1: - output = updated[0] - elif len(updated) == 2: - output = ' and '.join(updated) - elif len(updated) > 2: - output = updated[0] - for index in range(1,len(updated)): - if index < len(updated) - 2: - output = ', '.join([output, updated[index]]) - elif index == len(updated) - 2: - output = ', and '.join([output, updated[index]]) - else: - output = ''.join([output, updated[index]]) - db.tests.find_one_and_update({'_id': self._id}, {'$set': test}) - _output = f'The {output} of the test {"has" if len(updated) == 1 else "have"} been updated.' - flash(_output) - return jsonify({'success': _output}), 200 \ No newline at end of file + def end(self): + now = datetime.now() + if self.end_date > now: + 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.'}) + + def add_adjustment(self, time:int): + adjustments = self.adjustments if self.adjustments is not None else {} + code = secrets.token_hex(3).lower() + adjustments[code] = time + 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()}.'}) + + 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()}.'}) + 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}) + + 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 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 diff --git a/ref-test/app/models/user.py b/ref-test/app/models/user.py index 1be2392..4c217d9 100644 --- a/ref-test/app/models/user.py +++ b/ref-test/app/models/user.py @@ -2,13 +2,13 @@ from ..modules import db from ..tools.encryption import decrypt, encrypt from ..tools.logs import write -import secrets - from flask import flash, jsonify, session from flask.helpers import url_for -from flask_login import UserMixin, login_user, logout_user +from flask_login import current_user, login_user, logout_user, UserMixin from werkzeug.security import check_password_hash, generate_password_hash +import secrets +from uuid import uuid4 class User(UserMixin, db.Model): id = db.Column(db.String(36), primary_key=True) username = db.Column(db.String(128), nullable=False) @@ -20,6 +20,12 @@ class User(UserMixin, db.Model): 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_username(self): raise AttributeError('set_username is not a readable attribute.') @@ -57,17 +63,11 @@ class User(UserMixin, db.Model): return True, f'User {self.get_username()} was created successfully.' def login(self, remember:bool=False): - self.authenticated = True - db.session.add(self) - db.session.commit() login_user(self, remember = remember) write('users.log', f'User \'{self.get_username()}\' has logged in.') flash(message=f'Welcome {self.get_username()}', category='success') def logout(self): - self.authenticated = False - db.session.add(self) - db.session.commit() session['remembered_username'] = self.get_username() logout_user() write('users.log', f'User \'{self.get_username()}\' has logged out.') @@ -93,4 +93,13 @@ class User(UserMixin, db.Model): username = self.get_username() db.session.delete(self) db.session.commit() - write('users.log', f'User \'{username}\' was deleted.') # TODO add current user + write('users.log', f'User \'{username}\' was deleted by \'{current_user.get_username()}\'.') + + def update(self, password:str=None, email:str=None): + 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}) diff --git a/ref-test/app/tools/auth.py b/ref-test/app/tools/auth.py new file mode 100644 index 0000000..0a6dfbf --- /dev/null +++ b/ref-test/app/tools/auth.py @@ -0,0 +1,22 @@ +from .data import load +from ..models import User + +from flask import abort, redirect +from flask.helpers import url_for +from flask_login import current_user + +from functools import wraps + +def require_account_creation(function): + @wraps(function) + def wrapper(*args, **kwargs): + if User.query.count() == 0: return redirect(url_for('views._register')) + return function(*args, **kwargs) + return wrapper + +def disable_if_logged_in(function): + @wraps(function) + def wrapper(*args, **kwargs): + if current_user.is_authenticated: return abort(404) + return function(*args, **kwargs) + return wrapper \ No newline at end of file diff --git a/ref-test/app/tools/data.py b/ref-test/app/tools/data.py index a99cdce..1b68f67 100644 --- a/ref-test/app/tools/data.py +++ b/ref-test/app/tools/data.py @@ -1,4 +1,3 @@ -from main import Config from ..data import data as data_dir import json diff --git a/ref-test/app/tools/forms.py b/ref-test/app/tools/forms.py new file mode 100644 index 0000000..98060a3 --- /dev/null +++ b/ref-test/app/tools/forms.py @@ -0,0 +1,36 @@ + +from ..modules import db + +from wtforms.validators import ValidationError + +import json +from sqlalchemy.ext import mutable + +class JsonEncodedDict(db.TypeDecorator): + """Enables JSON storage by encoding and decoding on the fly.""" + impl = db.Text + + def process_bind_param(self, value, dialect): + if value is None: + return '{}' + else: + return json.dumps(value) + + def process_result_value(self, value, dialect): + if value is None: + return {} + else: + return json.loads(value) + +mutable.MutableDict.associate_with(JsonEncodedDict) + +def value(min:int=0, max:int=None): + if not max: + message = f'Value must be greater than {min}.' + else: + 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 diff --git a/ref-test/app/tools/logs.py b/ref-test/app/tools/logs.py index b095cf5..16e7888 100644 --- a/ref-test/app/tools/logs.py +++ b/ref-test/app/tools/logs.py @@ -1,4 +1,3 @@ -from main import Config from ..data import data from datetime import datetime diff --git a/ref-test/requirements.txt b/ref-test/requirements.txt index c08cf22..6d4d52f 100644 --- a/ref-test/requirements.txt +++ b/ref-test/requirements.txt @@ -2,7 +2,9 @@ blinker==1.4 cffi==1.15.0 click==8.1.3 cryptography==37.0.2 +dnspython==2.2.1 dominate==2.6.0 +email-validator==1.2.1 Flask==2.1.2 Flask-Bootstrap==3.3.7.1 Flask-Login==0.6.1 @@ -10,6 +12,7 @@ Flask-Mail==0.9.1 Flask-SQLAlchemy==2.5.1 Flask-WTF==1.0.1 greenlet==1.1.2 +idna==3.3 itsdangerous==2.1.2 Jinja2==3.1.2 MarkupSafe==2.1.1