API - move some app configuration parameters in database

This commit is contained in:
Sam 2019-11-13 18:40:01 +01:00
parent 6447d22d97
commit 649b773ba9
14 changed files with 619 additions and 17 deletions

View File

@ -29,6 +29,9 @@ install-db:
$(FLASK) db upgrade --directory $(MIGRATIONS) $(FLASK) db upgrade --directory $(MIGRATIONS)
$(FLASK) initdata $(FLASK) initdata
init-app-config:
$(FLASK) init-app-config
init-db: init-db:
$(FLASK) drop-db $(FLASK) drop-db
$(FLASK) db upgrade --directory $(MIGRATIONS) $(FLASK) db upgrade --directory $(MIGRATIONS)

View File

@ -26,12 +26,26 @@ def create_app():
bcrypt.init_app(app) bcrypt.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
# get configuration from database
from .application.models import AppConfig
from .application.utils import init_config, update_app_config_from_database
with app.app_context():
# Note: check if "app_config" table exist to avoid errors when
# dropping tables on dev environments
if db.engine.dialect.has_table(db.engine, 'app_config'):
db_app_config = AppConfig.query.one_or_none()
if not db_app_config:
_, db_app_config = init_config()
update_app_config_from_database(app, db_app_config)
from .users.auth import auth_blueprint # noqa from .users.auth import auth_blueprint # noqa
from .users.users import users_blueprint # noqa from .users.users import users_blueprint # noqa
from .activities.activities import activities_blueprint # noqa from .activities.activities import activities_blueprint # noqa
from .activities.records import records_blueprint # noqa from .activities.records import records_blueprint # noqa
from .activities.sports import sports_blueprint # noqa from .activities.sports import sports_blueprint # noqa
from .activities.stats import stats_blueprint # noqa from .activities.stats import stats_blueprint # noqa
from .application.config import config_blueprint # noqa
app.register_blueprint(users_blueprint, url_prefix='/api') app.register_blueprint(users_blueprint, url_prefix='/api')
app.register_blueprint(auth_blueprint, url_prefix='/api') app.register_blueprint(auth_blueprint, url_prefix='/api')
@ -39,6 +53,7 @@ def create_app():
app.register_blueprint(records_blueprint, url_prefix='/api') app.register_blueprint(records_blueprint, url_prefix='/api')
app.register_blueprint(sports_blueprint, url_prefix='/api') app.register_blueprint(sports_blueprint, url_prefix='/api')
app.register_blueprint(stats_blueprint, url_prefix='/api') app.register_blueprint(stats_blueprint, url_prefix='/api')
app.register_blueprint(config_blueprint, url_prefix='/api')
if app.debug: if app.debug:
logging.getLogger('sqlalchemy').setLevel(logging.WARNING) logging.getLogger('sqlalchemy').setLevel(logging.WARNING)

View File

@ -0,0 +1,146 @@
from fittrackee_api import appLog, db
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
from ..users.utils import authenticate_as_admin
from .models import AppConfig
from .utils import update_app_config_from_database
config_blueprint = Blueprint('config', __name__)
@config_blueprint.route('/config', methods=['GET'])
def get_application_config():
"""
Get Application config
**Example request**:
.. sourcecode:: http
GET /api/config HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"gpx_limit_import": 10,
"is_registration_enabled": false,
"max_single_file_size": 1048576,
"max_zip_file_size": 10485760,
"max_users": 0,
"registration": false
},
"status": "success"
}
:statuscode 200: success
:statuscode 500: Error on getting configuration.
"""
try:
config = AppConfig.query.one()
response_object = {'status': 'success', 'data': config.serialize()}
return jsonify(response_object), 200
except (MultipleResultsFound, NoResultFound) as e:
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error on getting configuration.',
}
return jsonify(response_object), 500
@config_blueprint.route('/config', methods=['PATCH'])
@authenticate_as_admin
def update_application_config(auth_user_id):
"""
Update Application config
Authenticated user must be an admin
**Example request**:
.. sourcecode:: http
GET /api/config HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"gpx_limit_import": 10,
"is_registration_enabled": true,
"max_single_file_size": 1048576,
"max_zip_file_size": 10485760,
"max_users": 10,
"registration": true
},
"status": "success"
}
:param integer auth_user_id: authenticate user id (from JSON Web Token)
:<json integrer gpx_limit_import: max number of files in zip archive
:<json integrer max_single_file_size: max size of a single file
:<json integrer max_zip_file_size: max size of a zip archive
:<json integrer max_users: max users allowed to register on instance
:<json boolean registration: is registration enabled ?
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: success
:statuscode 400: invalid payload
:statuscode 401:
- Provide a valid auth token.
- Signature expired. Please log in again.
- Invalid token. Please log in again.
:statuscode 403: You do not have permissions.
:statuscode 500: Error on updating configuration.
"""
config_data = request.get_json()
if not config_data:
response_object = {'status': 'error', 'message': 'Invalid payload.'}
return jsonify(response_object), 400
try:
config = AppConfig.query.one()
if 'gpx_limit_import' in config_data:
config.gpx_limit_import = config_data.get('gpx_limit_import')
if 'max_single_file_size' in config_data:
config.max_single_file_size = config_data.get(
'max_single_file_size'
)
if 'max_zip_file_size' in config_data:
config.max_zip_file_size = config_data.get('max_zip_file_size')
if 'max_users' in config_data:
config.max_users = config_data.get('max_users')
if 'registration' in config_data:
config.registration = config_data.get('registration')
db.session.commit()
update_app_config_from_database(current_app, config)
response_object = {'status': 'success', 'data': config.serialize()}
code = 200
except Exception as e:
appLog.error(e)
response_object = {
'status': 'error',
'message': 'Error on updating configuration.',
}
code = 500
return jsonify(response_object), code

View File

@ -0,0 +1,30 @@
from fittrackee_api import db
from ..users.models import User
class AppConfig(db.Model):
__tablename__ = 'app_config'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
registration = db.Column(db.Boolean, default=False, nullable=False)
max_users = db.Column(db.Integer, default=0, nullable=False)
gpx_limit_import = db.Column(db.Integer, default=10, nullable=False)
max_single_file_size = db.Column(
db.Integer, default=1048576, nullable=False
)
max_zip_file_size = db.Column(db.Integer, default=10485760, nullable=False)
@property
def is_registration_enabled(self):
nb_users = User.query.count()
return self.registration and nb_users < self.max_users
def serialize(self):
return {
"gpx_limit_import": self.gpx_limit_import,
"is_registration_enabled": self.is_registration_enabled,
"max_single_file_size": self.max_single_file_size,
"max_zip_file_size": self.max_zip_file_size,
"max_users": self.max_users,
"registration": self.registration,
}

View File

@ -0,0 +1,46 @@
import os
from fittrackee_api import db
from .models import AppConfig
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB
def init_config():
"""
init application configuration if not existing in database
Note: get some configuration values from env variables (for FitTrackee versions
prior to v0.3.0)
"""
existing_config = AppConfig.query.one_or_none()
if not existing_config:
config = AppConfig()
config.registration = (
False
if os.getenv('REACT_APP_ALLOW_REGISTRATION') == "false"
else True
)
config.max_users = 0
config.max_single_file_size = os.environ.get(
'REACT_APP_MAX_SINGLE_FILE_SIZE', MAX_FILE_SIZE
)
config.max_zip_file_size = os.environ.get(
'REACT_APP_MAX_ZIP_FILE_SIZE', MAX_FILE_SIZE * 10
)
db.session.add(config)
db.session.commit()
return True, config
return False, existing_config
def update_app_config_from_database(current_app, db_config):
current_app.config['gpx_limit_import'] = db_config.gpx_limit_import
current_app.config['max_single_file_size'] = db_config.max_single_file_size
current_app.config['MAX_CONTENT_LENGTH'] = db_config.max_zip_file_size
current_app.config['max_users'] = db_config.max_users
current_app.config['registration'] = db_config.registration
current_app.config[
'is_registration_enabled'
] = db_config.is_registration_enabled

View File

@ -2,8 +2,6 @@ import os
from flask import current_app from flask import current_app
MAX_FILE_SIZE = 1 * 1024 * 1024 # 1MB
class BaseConfig: class BaseConfig:
"""Base configuration""" """Base configuration"""
@ -15,19 +13,8 @@ class BaseConfig:
TOKEN_EXPIRATION_DAYS = 30 TOKEN_EXPIRATION_DAYS = 30
TOKEN_EXPIRATION_SECONDS = 0 TOKEN_EXPIRATION_SECONDS = 0
UPLOAD_FOLDER = os.path.join(current_app.root_path, 'uploads') UPLOAD_FOLDER = os.path.join(current_app.root_path, 'uploads')
# for gpx zip
MAX_CONTENT_LENGTH = int(
os.environ.get('REACT_APP_MAX_ZIP_FILE_SIZE', MAX_FILE_SIZE * 10)
)
# for single file (gpx or picture)
MAX_SINGLE_FILE = int(
os.environ.get('REACT_APP_MAX_SINGLE_FILE_SIZE', MAX_FILE_SIZE)
)
PICTURE_ALLOWED_EXTENSIONS = {'jpg', 'png', 'gif'} PICTURE_ALLOWED_EXTENSIONS = {'jpg', 'png', 'gif'}
ACTIVITY_ALLOWED_EXTENSIONS = {'gpx', 'zip'} ACTIVITY_ALLOWED_EXTENSIONS = {'gpx', 'zip'}
REGISTRATION_ALLOWED = (
False if os.getenv('REACT_APP_ALLOW_REGISTRATION') == "false" else True
)
class DevelopmentConfig(BaseConfig): class DevelopmentConfig(BaseConfig):

View File

@ -4,16 +4,58 @@ import os
import pytest import pytest
from fittrackee_api import create_app, db from fittrackee_api import create_app, db
from fittrackee_api.activities.models import Activity, ActivitySegment, Sport from fittrackee_api.activities.models import Activity, ActivitySegment, Sport
from fittrackee_api.application.models import AppConfig
from fittrackee_api.application.utils import update_app_config_from_database
from fittrackee_api.users.models import User from fittrackee_api.users.models import User
os.environ["FLASK_ENV"] = 'testing' os.environ["FLASK_ENV"] = 'testing'
os.environ["APP_SETTINGS"] = 'fittrackee_api.config.TestingConfig' os.environ["APP_SETTINGS"] = 'fittrackee_api.config.TestingConfig'
def app_config_with_registration():
config = AppConfig()
config.gpx_limit_import = 10
config.max_single_file_size = 1 * 1024 * 1024
config.max_zip_file_size = 1 * 1024 * 1024 * 10
config.max_users = 10
config.registration = True
db.session.add(config)
db.session.commit()
return config
def app_config_registration_disabled():
config = AppConfig()
config.gpx_limit_import = 20
config.max_single_file_size = 10485
config.max_zip_file_size = 104850
config.max_users = 0
config.registration = False
db.session.add(config)
db.session.commit()
return config
@pytest.fixture @pytest.fixture
def app(): def app():
app = create_app() app = create_app()
app.config['REGISTRATION_ALLOWED'] = True with app.app_context():
db.create_all()
app_db_config = app_config_with_registration()
update_app_config_from_database(app, app_db_config)
yield app
db.session.remove()
db.drop_all()
# close unused idle connections => avoid the following error:
# FATAL: remaining connection slots are reserved for non-replication
# superuser connections
db.engine.dispose()
return app
@pytest.fixture
def app_no_config():
app = create_app()
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
yield app yield app
@ -29,9 +71,10 @@ def app():
@pytest.fixture @pytest.fixture
def app_no_registration(): def app_no_registration():
app = create_app() app = create_app()
app.config['REGISTRATION_ALLOWED'] = False
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
app_db_config = app_config_registration_disabled()
update_app_config_from_database(app, app_db_config)
yield app yield app
db.session.remove() db.session.remove()
db.drop_all() db.drop_all()
@ -39,6 +82,19 @@ def app_no_registration():
return app return app
@pytest.fixture()
def app_config():
config = AppConfig()
config.gpx_limit_import = 10
config.max_single_file_size = 1048576
config.max_zip_file_size = 10485760
config.max_users = 0
config.registration = False
db.session.add(config)
db.session.commit()
return config
@pytest.fixture() @pytest.fixture()
def user_1(): def user_1():
user = User(username='test', email='test@test.com', password='12345678') user = User(username='test', email='test@test.com', password='12345678')

View File

@ -0,0 +1,214 @@
import json
def test_get_config(app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/config',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert data['data']['gpx_limit_import'] == 10
assert data['data']['is_registration_enabled'] is True
assert data['data']['max_single_file_size'] == 1048576
assert data['data']['max_zip_file_size'] == 10485760
assert data['data']['max_users'] == 10
assert data['data']['registration'] is True
def test_get_config_no_config(app_no_config, user_1_admin):
client = app_no_config.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/config',
content_type='application/json',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'Error on getting configuration.' in data['message']
def test_get_config_several_config(
app, app_config, user_1_admin
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.get(
'/api/config',
content_type='application/json',
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'Error on getting configuration.' in data['message']
def test_update_config_as_admin(app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(
dict(registration=True, max_users=10)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert data['data']['gpx_limit_import'] == 10
assert data['data']['is_registration_enabled'] is True
assert data['data']['max_single_file_size'] == 1048576
assert data['data']['max_zip_file_size'] == 10485760
assert data['data']['max_users'] == 10
assert data['data']['registration'] is True
def test_update_full_config_as_admin(app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(
dict(
gpx_limit_import=20,
max_single_file_size=10000,
max_zip_file_size=25000,
max_users=50,
registration=True,
)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert data['data']['gpx_limit_import'] == 20
assert data['data']['is_registration_enabled'] is True
assert data['data']['max_single_file_size'] == 10000
assert data['data']['max_zip_file_size'] == 25000
assert data['data']['max_users'] == 50
assert data['data']['registration'] is True
def test_update_config_not_admin(app, user_1):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='test@test.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(
dict(registration=True, max_users=10)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 403
assert 'success' not in data['status']
assert 'error' in data['status']
assert 'You do not have permissions.' in data['message']
def test_update_config_invalid_payload(app, user_1_admin):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'error' in data['status']
assert 'Invalid payload.' in data['message']
def test_update_config_no_config(app_no_config, user_1_admin):
client = app_no_config.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
response = client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(
dict(registration=True, max_users=10)
),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
data = json.loads(response.data.decode())
assert response.status_code == 500
assert 'error' in data['status']
assert 'Error on updating configuration.' in data['message']

View File

@ -0,0 +1,14 @@
from fittrackee_api.application.models import AppConfig
def test_application_config(app):
app_config = AppConfig.query.first()
assert 1 == app_config.id
serialized_app_config = app_config.serialize()
assert serialized_app_config['gpx_limit_import'] == 10
assert serialized_app_config['is_registration_enabled'] is True
assert serialized_app_config['max_single_file_size'] == 1048576
assert serialized_app_config['max_zip_file_size'] == 10485760
assert serialized_app_config['max_users'] == 10
assert serialized_app_config['registration'] is True

View File

@ -281,6 +281,46 @@ def test_user_registration_not_allowed(app_no_registration):
assert data['message'] == 'Error. Registration is disabled.' assert data['message'] == 'Error. Registration is disabled.'
def test_user_registration_max_users_exceeded(
app, user_1_admin, user_2, user_3
):
client = app.test_client()
resp_login = client.post(
'/api/auth/login',
data=json.dumps(dict(email='admin@example.com', password='12345678')),
content_type='application/json',
)
client.patch(
'/api/config',
content_type='application/json',
data=json.dumps(dict(max_users=3, registration=True)),
headers=dict(
Authorization='Bearer '
+ json.loads(resp_login.data.decode())['auth_token']
),
)
response = client.post(
'/api/auth/register',
data=json.dumps(
dict(
username='user4',
email='user4@test.com',
password='12345678',
password_conf='12345678',
)
),
content_type='application/json',
)
assert response.content_type == 'application/json'
assert response.status_code == 403
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'Error. Registration is disabled.'
def test_login_registered_user(app, user_1): def test_login_registered_user(app, user_1):
client = app.test_client() client = app.test_client()
response = client.post( response = client.post(

View File

@ -78,7 +78,7 @@ def register_user():
Error. Please try again or contact the administrator. Error. Please try again or contact the administrator.
""" """
if not current_app.config.get('REGISTRATION_ALLOWED'): if not current_app.config.get('is_registration_enabled'):
response_object = { response_object = {
'status': 'error', 'status': 'error',
'message': 'Error. Registration is disabled.', 'message': 'Error. Registration is disabled.',

View File

@ -53,7 +53,7 @@ def verify_extension_and_size(file_type, req):
if '.' in file.filename if '.' in file.filename
else None else None
) )
max_file_size = current_app.config['MAX_SINGLE_FILE'] max_file_size = current_app.config['max_single_file_size']
if not ( if not (
file_extension file_extension

View File

@ -0,0 +1,36 @@
"""empty message
Revision ID: 8a0aad4c838c
Revises: 1345afe3b11d
Create Date: 2019-11-13 13:14:20.147296
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a0aad4c838c'
down_revision = '1345afe3b11d'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('app_config',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('registration', sa.Boolean(), nullable=False),
sa.Column('max_users', sa.Integer(), nullable=False),
sa.Column('gpx_limit_import', sa.Integer(), nullable=False),
sa.Column('max_single_file_size', sa.Integer(), nullable=False),
sa.Column('max_zip_file_size', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('app_config')
# ### end Alembic commands ###

View File

@ -3,6 +3,7 @@ import shutil
from fittrackee_api import create_app, db from fittrackee_api import create_app, db
from fittrackee_api.activities.models import Activity, Sport from fittrackee_api.activities.models import Activity, Sport
from fittrackee_api.activities.utils import update_activity from fittrackee_api.activities.utils import update_activity
from fittrackee_api.application.utils import init_config
from fittrackee_api.users.models import User from fittrackee_api.users.models import User
from tqdm import tqdm from tqdm import tqdm
@ -75,5 +76,19 @@ def recalculate():
db.session.commit() db.session.commit()
@app.cli.command('init-app-config')
def init_app_config():
"""Init application configuration."""
print("Init application configuration")
config_created, _ = init_config()
if config_created:
print("Creation done!")
else:
print(
"Application configuration already existing in database. "
"Please use web application to update it."
)
if __name__ == '__main__': if __name__ == '__main__':
app.run() app.run()