Compare commits
30 Commits
Author | SHA1 | Date | |
---|---|---|---|
294f1e42f7 | |||
070ce19fcc | |||
615e59fc6d | |||
68314a4ed2 | |||
b90761fd2c | |||
af03193217 | |||
730a75c44d | |||
70883db5ad | |||
7cefb487da | |||
2e1b01ec9b | |||
a7a5a03991 | |||
b36c6bfd18 | |||
a613b0006b | |||
d4db8692e7 | |||
37ad36da31 | |||
d140f93d25 | |||
26a6248a61 | |||
9f8ea16974 | |||
bc5ec44145 | |||
ff5b19fa0b | |||
6c50be49c6 | |||
8bfe028e2c | |||
519394a656 | |||
9e1c9caec6 | |||
ea850c9ae2 | |||
591b868920 | |||
91dc93758a | |||
5d27baee08 | |||
1254cf3698 | |||
efab086057 |
@ -1,5 +1,7 @@
|
||||
SERVER_NAME= # URL where this will be hosted.
|
||||
|
||||
TZ=Europe/London # Time Zone
|
||||
|
||||
## Flask Configuration
|
||||
SECRET_KEY= # Long, secure, secret string.
|
||||
DATA=./data/
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -149,4 +149,7 @@ ref-test/testing.py
|
||||
database/data/
|
||||
|
||||
# Ignore Encryption Keyfile
|
||||
.encryption.key
|
||||
.encryption.key
|
||||
|
||||
# Ignore Data Dir
|
||||
**/data/*
|
56
README.md
56
README.md
@ -166,3 +166,59 @@ The app uses [OpenDyslexic](https://opendyslexic.org/), which is available on-li
|
||||
It also has the option of rendering in other system fonts, but this can vary depending on your operating system.
|
||||
Because these are proprietary fonts, they cannot be made available on-line in the same way as open source ones should your system not have them.
|
||||
Some fonts may not display correctly as a result.
|
||||
|
||||
## Updating the Installation
|
||||
|
||||
If the app is updated, you can update the version on your installation using the following method:
|
||||
|
||||
### Navigate to the root folder
|
||||
|
||||
This will be the root folder into which you cloned the git repository when you set the app up.
|
||||
|
||||
### Stash your local changes
|
||||
|
||||
When you update the code, there is a risk the changes you made to your configuration will be overwritten.
|
||||
To avoid this, use the following command:
|
||||
|
||||
```git stash```
|
||||
|
||||
This will stash the changes you made, and we can re-apply the changes once the new code has been downloaded.
|
||||
If you do not have any other changes stashed, the index number of these changes should be `0` in a later step.
|
||||
If there are other changes, make sure to note what the correct index number for the stashed changes is.
|
||||
|
||||
### Take down the Docker containers
|
||||
|
||||
We will need to stop the current containers with the following command:
|
||||
|
||||
```sudo docker compose down```
|
||||
|
||||
This may take a few seconds.
|
||||
|
||||
### Pull the updated code
|
||||
|
||||
Download the updated code from the Git repository:
|
||||
|
||||
```git pull```
|
||||
|
||||
This step might fail if you have any un-stashed local changed.
|
||||
|
||||
### Re-Apply your local configurations
|
||||
|
||||
Because we stashed our local configurations, we can re-apply them once again:
|
||||
|
||||
```git stash pop 0```
|
||||
|
||||
The index number (`0`) is assuming there were no other changes saved on your git repository.
|
||||
If you have a different index number for the relevant changes from the above step, change this accordingly.
|
||||
|
||||
### Re-build the docker image
|
||||
|
||||
Now that we have the base code downloaded, we just need to update the docker image:
|
||||
|
||||
```sudo docker compose build app```
|
||||
|
||||
### Re-build the containers
|
||||
|
||||
This is the same last step as running the containers in the last step of the installation:
|
||||
|
||||
```sudo docker compose up -d```
|
||||
|
@ -1,5 +1,8 @@
|
||||
version: '3.9'
|
||||
|
||||
volumes:
|
||||
data:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
container_name: reftest_server
|
||||
@ -8,6 +11,7 @@ services:
|
||||
- ./certbot:/etc/letsencrypt:ro
|
||||
- ./nginx:/etc/nginx
|
||||
- ./src/html:/usr/share/nginx/html/
|
||||
- ./ref-test/app/editor/static:/usr/share/nginx/html/admin/editor/static
|
||||
- ./ref-test/app/admin/static:/usr/share/nginx/html/admin/static
|
||||
- ./ref-test/app/quiz/static:/usr/share/nginx/html/quiz/static
|
||||
- ./ref-test/app/root:/usr/share/nginx/html/root
|
||||
@ -30,7 +34,7 @@ services:
|
||||
ports:
|
||||
- 5000
|
||||
volumes:
|
||||
- ./ref-test/data:/ref-test/data
|
||||
- data:/ref-test/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- frontend
|
||||
|
@ -46,8 +46,6 @@ if [ ! -e "$data_path/lets-encrypt-x3-cross-signed.pem" ]; then
|
||||
echo "### Downloading lets-encrypt-x3-cross-signed.pem ..."
|
||||
wget -O $data_path/lets-encrypt-x3-cross-signed.pem \
|
||||
"https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
|
||||
docker compose run --rm --entrypoint "\
|
||||
openssl dhparam -out /etc/letsencrypt/ssl-dhparams.pem 4096" certbot
|
||||
echo
|
||||
fi
|
||||
|
||||
@ -79,7 +77,7 @@ esac
|
||||
if [ $staging != "0" ]; then staging_arg="--staging"; fi
|
||||
|
||||
docker compose run --rm --entrypoint "\
|
||||
certbot certonly --webroot -w /var/www/html \
|
||||
certbot certonly --non-interactive --webroot -w /var/www/html \
|
||||
$staging_arg \
|
||||
$email_arg \
|
||||
$domain_args \
|
||||
|
@ -29,6 +29,11 @@ server {
|
||||
alias /usr/share/nginx/html/admin/static/;
|
||||
}
|
||||
|
||||
location ^~ /admin/editor/static/ {
|
||||
include /etc/nginx/mime.types;
|
||||
alias /usr/share/nginx/html/admin/editor/static/;
|
||||
}
|
||||
|
||||
location / {
|
||||
include /etc/nginx/conf.d/proxy_headers.conf;
|
||||
proxy_pass http://reftest;
|
||||
|
@ -1,5 +1,8 @@
|
||||
FROM python:3.10-slim
|
||||
ARG DATA=./data/
|
||||
ENV DATA=$DATA
|
||||
WORKDIR /ref-test
|
||||
COPY . .
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
RUN chmod +x install.py && ./install.py
|
||||
CMD [ "gunicorn", "-b", "0.0.0.0:5000", "-w", "5", "wsgi:app" ]
|
@ -1,5 +1,4 @@
|
||||
from .config import Production as Config
|
||||
from .install import install_app
|
||||
from .models import User
|
||||
from .extensions import bootstrap, csrf, db, login_manager, mail
|
||||
|
||||
@ -53,7 +52,5 @@ def create_app():
|
||||
app.register_blueprint(views)
|
||||
app.register_blueprint(quiz)
|
||||
app.register_blueprint(editor, url_prefix='/admin/editor')
|
||||
|
||||
install_app(app)
|
||||
|
||||
return app
|
@ -2,7 +2,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for(request.endpoint, **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
<form name="form-update-password" class="form-display form-post" action="{{ url_for('admin._update_password', **request.view_args) }}" data-rel-success="{{ url_for('admin._login') }}">
|
||||
{% include "admin/components/server-alerts.html" %}
|
||||
<h2 class="form-heading">Update Password</h2>
|
||||
{{ form.hidden_tag() }}
|
||||
|
@ -24,7 +24,7 @@
|
||||
</div>
|
||||
{{ entry.get_email() }}
|
||||
</li>
|
||||
{% if entry.club %}
|
||||
{% if entry.get_club() %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
|
@ -22,7 +22,7 @@
|
||||
</div>
|
||||
{{ entry.get_email() }}
|
||||
</li>
|
||||
{% if entry.club %}
|
||||
{% if entry.get_club() %}
|
||||
<li class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">Club</h5>
|
||||
|
@ -40,7 +40,7 @@
|
||||
{{ entry.get_surname() }}, {{ entry.get_first_name() }}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.club %}
|
||||
{% if entry.get_club() %}
|
||||
{{ entry.get_club() }}
|
||||
{% endif %}
|
||||
</td>
|
||||
|
@ -9,7 +9,7 @@ from flask import abort, Blueprint, jsonify, render_template, redirect, request,
|
||||
from flask.helpers import flash, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from json import loads
|
||||
from os import path
|
||||
import secrets
|
||||
@ -91,7 +91,7 @@ def _register():
|
||||
flash(message=message, category='error')
|
||||
return jsonify({'error': message}), 401
|
||||
return send_errors_to_client(form=form)
|
||||
return render_template('admin/auth/register.html', form=form)
|
||||
return render_template('/admin/auth/register.html', form=form)
|
||||
|
||||
@admin.route('/reset/', methods=['GET','POST'])
|
||||
def _reset():
|
||||
@ -117,7 +117,8 @@ def _reset():
|
||||
user.clear_reset_tokens()
|
||||
if request.args.get('verification') == verification_token:
|
||||
form = UpdatePassword()
|
||||
return render_template('/auth/update_password.html', form=form, user=user.id)
|
||||
session['user'] = user.id
|
||||
return render_template('/admin/auth/update-password.html', form=form)
|
||||
flash('The verification of your password reset request failed and the token has been invalidated. Please make a new reset password request.', 'error')
|
||||
|
||||
return render_template('/admin/auth/reset.html', form=form)
|
||||
@ -126,7 +127,7 @@ def _reset():
|
||||
def _update_password():
|
||||
form = UpdatePassword()
|
||||
if form.validate_on_submit():
|
||||
user = request.form.get('user')
|
||||
user = session.pop('user')
|
||||
user = User.query.filter_by(id=user).first()
|
||||
user.update(password=request.form.get('password'))
|
||||
session['remembered_username'] = user.get_username()
|
||||
@ -258,6 +259,8 @@ def _tests(filter:str=None):
|
||||
if filter not in ['create','active','scheduled','expired','all']: return redirect(url_for('admin._tests', filter='active'))
|
||||
if filter == 'create':
|
||||
form = CreateTest()
|
||||
form.start_date.default = datetime.now()
|
||||
form.expiry_date.default = date.today() + timedelta(days=1)
|
||||
form.time_limit.choices = get_time_options()
|
||||
form.dataset.choices = get_dataset_choices()
|
||||
form.time_limit.default='none'
|
||||
|
@ -5,7 +5,7 @@ load_dotenv('../.env')
|
||||
|
||||
class Config(object):
|
||||
APP_HOST = '0.0.0.0'
|
||||
DATA = os.getenv('DATA')
|
||||
DATA = './data/'
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
||||
@ -15,16 +15,16 @@ class Config(object):
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
MAIL_SERVER = os.getenv('MAIL_SERVER')
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT'))
|
||||
MAIL_PORT = int(os.getenv('MAIL_PORT') or 25)
|
||||
MAIL_USE_TLS = False
|
||||
MAIL_USE_SSL = False
|
||||
MAIL_DEBUG = False
|
||||
MAIL_USERNAME = os.getenv('MAIL_USERNAME')
|
||||
MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
|
||||
MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
|
||||
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS'))
|
||||
MAIL_MAX_EMAILS = int(os.getenv('MAIL_MAX_EMAILS') or 25)
|
||||
MAIL_SUPPRESS_SEND = False
|
||||
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS'))
|
||||
MAIL_ASCII_ATTACHMENTS = bool(os.getenv('MAIL_ASCII_ATTACHMENTS') or True)
|
||||
|
||||
class Production(Config):
|
||||
pass
|
||||
|
@ -6,8 +6,6 @@ from wtforms import BooleanField, IntegerField, PasswordField, SelectField, Stri
|
||||
from wtforms.fields import DateTimeLocalField
|
||||
from wtforms.validators import InputRequired, Email, EqualTo, Length, Optional
|
||||
|
||||
from datetime import date, datetime, 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.')])
|
||||
@ -51,8 +49,8 @@ class UpdateAccount(FlaskForm):
|
||||
password_reenter = PasswordField('Re-Enter New Password', validators=[EqualTo('password', message='Passwords do not match.')])
|
||||
|
||||
class CreateTest(FlaskForm):
|
||||
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = datetime.now() )
|
||||
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()], default = date.today() + timedelta(days=1) )
|
||||
start_date = DateTimeLocalField('Start Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
|
||||
expiry_date = DateTimeLocalField('Expiry Date', format='%Y-%m-%dT%H:%M', validators=[InputRequired()] )
|
||||
time_limit = SelectField('Time Limit')
|
||||
dataset = SelectField('Question Dataset')
|
||||
|
||||
|
@ -1,34 +0,0 @@
|
||||
from .extensions import db
|
||||
from .tools.data import save
|
||||
from .tools.logs import write
|
||||
|
||||
from sqlalchemy_utils import create_database, database_exists
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from os import mkdir, path
|
||||
from pathlib import Path
|
||||
|
||||
def install_app(app):
|
||||
with app.app_context():
|
||||
data = Path(app.config.get('DATA'))
|
||||
database_uri = app.config.get('SQLALCHEMY_DATABASE_URI')
|
||||
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')
|
||||
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
|
||||
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
|
||||
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
|
||||
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
|
||||
if not database_exists(database_uri):
|
||||
create_database(database_uri)
|
||||
write('system.log', 'No database found. Creating a new database.')
|
||||
from .models import Entry, Dataset, Test, User
|
||||
db.create_all()
|
||||
write('system.log', 'Creating database schema.')
|
||||
if not path.isfile(f'./{data}/.encryption.key'):
|
||||
write('system.log', 'No encryption key found. Generating new encryption key.')
|
||||
with open(f'./{data}/.encryption.key', 'wb') as key_file:
|
||||
key = Fernet.generate_key()
|
||||
key_file.write(key)
|
@ -102,7 +102,7 @@ class Dataset(db.Model):
|
||||
with open(file_path, 'w') as file:
|
||||
dump(data, file, indent=2)
|
||||
write('system.log', f'Dataset {self.id} edited by {current_user.get_username()}.')
|
||||
flash(f'Dataset {self.name} successfully edited.', 'success')
|
||||
flash(f'Dataset {self.get_name()} successfully edited.', 'success')
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
return True, 'Dataset successfully edited.'
|
@ -194,7 +194,8 @@ class User(UserMixin, db.Model):
|
||||
if entry.get_email() == email and not entry == self: return False, f'The email address {email} is already in use.'
|
||||
self.set_email(email)
|
||||
db.session.commit()
|
||||
write('system.log', f'Information for user {self.get_username()} has been updated by {current_user.get_username()}.')
|
||||
_current_user = current_user.get_username() if current_user.is_authenticated else 'anonymous'
|
||||
write('system.log', f'Information for user {self.get_username()} has been updated by {_current_user}.')
|
||||
if notify:
|
||||
message = Message(
|
||||
subject='RefTest | Account Update',
|
||||
@ -202,7 +203,7 @@ class User(UserMixin, db.Model):
|
||||
bcc=[old_email,current_user.get_email()],
|
||||
body=f"""
|
||||
Hello {self.get_username()},\n\n
|
||||
Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.\n\n
|
||||
Your administrator account for the SKA RefTest App has been updated by {_current_user}.\n\n
|
||||
Your new account details are as follows:\n\n
|
||||
Email: {email}\n
|
||||
Password: {password if password else '<same as old>'}\n\n
|
||||
@ -213,7 +214,7 @@ class User(UserMixin, db.Model):
|
||||
""",
|
||||
html=f"""
|
||||
<p>Hello {self.get_username()},</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been updated by {current_user.get_username()}.</p>
|
||||
<p>Your administrator account for the SKA RefTest App has been updated by {_current_user}.</p>
|
||||
<p>Your new account details are as follows:</p>
|
||||
<p>Email: {email} <br/> Password: <strong>{password if password else '<same as old>'}</strong></p>
|
||||
<p>You can update your email address and password by logging in to the admin console using the following URL:</p>
|
||||
|
@ -146,7 +146,7 @@ $("#btn-start-quiz").click(function(event){
|
||||
data: JSON.stringify({'id': id}),
|
||||
contentType: "application/json",
|
||||
success: function(response) {
|
||||
$(this).fadeOut();
|
||||
$("#btn-start-quiz").fadeOut();
|
||||
$(".btn-quiz-return").fadeIn();
|
||||
$(".quiz-console").fadeIn();
|
||||
$("#quiz-settings").fadeOut();
|
||||
|
@ -123,7 +123,7 @@
|
||||
<div class="container question-container quiz-start-text">
|
||||
<h4 class="question-title">Sample Question</h4>
|
||||
<p class="question-header">
|
||||
Korfball is a mixed-sex, controlled-contact, indoor, invasion ball sport. The sport originated in the Netherlands. It is a mixed-sex team sport. Its governing body is the International Korball Federation. There are numerous korfball leagues and associations around the world. A korfball match is officiated by a referee.
|
||||
Korfball is a mixed-sex, controlled-contact, indoor, invasion, team ball sport. The sport originated in the Netherlands. Its governing body is the International Korball Federation. There are numerous korfball leagues and associations around the world. A korfball match is officiated by a referee.
|
||||
</p>
|
||||
<p class="question-text">
|
||||
In order to be a referee, what do you need to know?
|
||||
|
@ -11,7 +11,7 @@
|
||||
|
||||
<strong class="results-details">Email Address</strong>: {{ entry.get_email() }} <br />
|
||||
|
||||
{% if entry.club %}
|
||||
{% if entry.get_club() %}
|
||||
<strong class="results-details">Club</strong>: {{ entry.get_club() }} <br />
|
||||
{% endif%}
|
||||
|
||||
|
@ -57,5 +57,5 @@ def get_dataset_choices():
|
||||
return dataset_choices
|
||||
|
||||
def send_errors_to_client(form):
|
||||
errors = [*form.errors]
|
||||
errors = [*form.errors.values()]
|
||||
return jsonify({ 'error': errors}), 400
|
1
ref-test/data/.gitignore
vendored
1
ref-test/data/.gitignore
vendored
@ -1 +0,0 @@
|
||||
*
|
34
ref-test/install.py
Executable file
34
ref-test/install.py
Executable file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python
|
||||
from main import app
|
||||
from app.extensions import db
|
||||
from app.tools.data import save
|
||||
from app.tools.logs import write
|
||||
from sqlalchemy_utils import create_database, database_exists
|
||||
from cryptography.fernet import Fernet
|
||||
from os import mkdir, path
|
||||
from pathlib import Path
|
||||
|
||||
data = Path(app.config.get('DATA'))
|
||||
database_uri = app.config.get('SQLALCHEMY_DATABASE_URI')
|
||||
|
||||
with app.app_context():
|
||||
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', 'w') as file: file.write(f'*')
|
||||
if not path.isfile(f'./{data}/config.json'): save({}, 'config.json')
|
||||
if not path.isdir(f'./{data}/logs'): mkdir(f'./{data}/logs')
|
||||
if not path.isfile(f'./{data}/logs/users.log'): write('users.log', 'Log file created.')
|
||||
if not path.isfile(f'./{data}/logs/system.log'): write('system.log', 'Log file created.')
|
||||
if not path.isfile(f'./{data}/logs/tests.log'): write('tests.log', 'Log file created.')
|
||||
if not database_exists(database_uri):
|
||||
create_database(database_uri)
|
||||
write('system.log', 'No database found. Creating a new database.')
|
||||
from app.models import *
|
||||
db.create_all()
|
||||
write('system.log', 'Creating database schema.')
|
||||
if not path.isfile(f'./{data}/.encryption.key'):
|
||||
write('system.log', 'No encryption key found. Generating new encryption key.')
|
||||
with open(f'./{data}/.encryption.key', 'wb') as key_file:
|
||||
key = Fernet.generate_key()
|
||||
key_file.write(key)
|
Reference in New Issue
Block a user