diff --git a/Makefile b/Makefile index 36718ec9..efcb32a8 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,9 @@ install-db: $(FLASK) db upgrade --directory $(MIGRATIONS) $(FLASK) initdata +init-app-config: + $(FLASK) init-app-config + init-db: $(FLASK) drop-db $(FLASK) db upgrade --directory $(MIGRATIONS) diff --git a/fittrackee_api/fittrackee_api/__init__.py b/fittrackee_api/fittrackee_api/__init__.py index 939dc366..17bea178 100644 --- a/fittrackee_api/fittrackee_api/__init__.py +++ b/fittrackee_api/fittrackee_api/__init__.py @@ -26,12 +26,26 @@ def create_app(): bcrypt.init_app(app) 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.users import users_blueprint # noqa from .activities.activities import activities_blueprint # noqa from .activities.records import records_blueprint # noqa from .activities.sports import sports_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(auth_blueprint, url_prefix='/api') @@ -39,6 +53,7 @@ def create_app(): app.register_blueprint(records_blueprint, url_prefix='/api') app.register_blueprint(sports_blueprint, url_prefix='/api') app.register_blueprint(stats_blueprint, url_prefix='/api') + app.register_blueprint(config_blueprint, url_prefix='/api') if app.debug: logging.getLogger('sqlalchemy').setLevel(logging.WARNING) diff --git a/fittrackee_api/fittrackee_api/application/config.py b/fittrackee_api/fittrackee_api/application/config.py new file mode 100644 index 00000000..90857b53 --- /dev/null +++ b/fittrackee_api/fittrackee_api/application/config.py @@ -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) + + : 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(): db.create_all() yield app @@ -29,9 +71,10 @@ def app(): @pytest.fixture def app_no_registration(): app = create_app() - app.config['REGISTRATION_ALLOWED'] = False with app.app_context(): db.create_all() + app_db_config = app_config_registration_disabled() + update_app_config_from_database(app, app_db_config) yield app db.session.remove() db.drop_all() @@ -39,6 +82,19 @@ def app_no_registration(): 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() def user_1(): user = User(username='test', email='test@test.com', password='12345678') diff --git a/fittrackee_api/fittrackee_api/tests/test_app_config_api.py b/fittrackee_api/fittrackee_api/tests/test_app_config_api.py new file mode 100644 index 00000000..bd1991ab --- /dev/null +++ b/fittrackee_api/fittrackee_api/tests/test_app_config_api.py @@ -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'] diff --git a/fittrackee_api/fittrackee_api/tests/test_app_config_model.py b/fittrackee_api/fittrackee_api/tests/test_app_config_model.py new file mode 100644 index 00000000..75c6dabf --- /dev/null +++ b/fittrackee_api/fittrackee_api/tests/test_app_config_model.py @@ -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 diff --git a/fittrackee_api/fittrackee_api/tests/test_auth_api.py b/fittrackee_api/fittrackee_api/tests/test_auth_api.py index 0c7b084e..185b6fa6 100644 --- a/fittrackee_api/fittrackee_api/tests/test_auth_api.py +++ b/fittrackee_api/fittrackee_api/tests/test_auth_api.py @@ -281,6 +281,46 @@ def test_user_registration_not_allowed(app_no_registration): 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): client = app.test_client() response = client.post( diff --git a/fittrackee_api/fittrackee_api/users/auth.py b/fittrackee_api/fittrackee_api/users/auth.py index a7fd0792..9dcb80af 100644 --- a/fittrackee_api/fittrackee_api/users/auth.py +++ b/fittrackee_api/fittrackee_api/users/auth.py @@ -78,7 +78,7 @@ def register_user(): 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 = { 'status': 'error', 'message': 'Error. Registration is disabled.', diff --git a/fittrackee_api/fittrackee_api/users/utils.py b/fittrackee_api/fittrackee_api/users/utils.py index f99a1aad..7987fca7 100644 --- a/fittrackee_api/fittrackee_api/users/utils.py +++ b/fittrackee_api/fittrackee_api/users/utils.py @@ -53,7 +53,7 @@ def verify_extension_and_size(file_type, req): if '.' in file.filename else None ) - max_file_size = current_app.config['MAX_SINGLE_FILE'] + max_file_size = current_app.config['max_single_file_size'] if not ( file_extension diff --git a/fittrackee_api/migrations/versions/14_8a0aad4c838c_add_app_config_in_database.py b/fittrackee_api/migrations/versions/14_8a0aad4c838c_add_app_config_in_database.py new file mode 100644 index 00000000..45a13925 --- /dev/null +++ b/fittrackee_api/migrations/versions/14_8a0aad4c838c_add_app_config_in_database.py @@ -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 ### diff --git a/fittrackee_api/server.py b/fittrackee_api/server.py index e49e71e8..93686e38 100644 --- a/fittrackee_api/server.py +++ b/fittrackee_api/server.py @@ -3,6 +3,7 @@ import shutil from fittrackee_api import create_app, db from fittrackee_api.activities.models import Activity, Sport from fittrackee_api.activities.utils import update_activity +from fittrackee_api.application.utils import init_config from fittrackee_api.users.models import User from tqdm import tqdm @@ -75,5 +76,19 @@ def recalculate(): 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__': app.run()