├── .coveragerc ├── .dockerignore ├── .editorconfig ├── .gitignore ├── .pylintrc ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── app ├── __init__.py ├── extensions │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── api.py │ │ ├── http_exceptions.py │ │ ├── namespace.py │ │ ├── parameters.py │ │ └── webargs_parser.py │ └── auth │ │ ├── __init__.py │ │ └── oauth2.py ├── modules │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ └── mongo_helpers.py │ ├── auth │ │ ├── __init__.py │ │ ├── models.py │ │ ├── parameters.py │ │ ├── resources.py │ │ ├── schemas.py │ │ └── views.py │ ├── teams │ │ ├── __init__.py │ │ ├── models.py │ │ ├── parameters.py │ │ ├── resources.py │ │ └── schemas.py │ └── users │ │ ├── __init__.py │ │ ├── models.py │ │ ├── parameters.py │ │ ├── permissions │ │ ├── __init__.py │ │ └── rules.py │ │ ├── resources.py │ │ └── schemas.py ├── requirements.txt └── templates │ ├── authorize.html │ ├── home.html │ ├── swagger-ui-libs.html │ └── swagger-ui.html ├── config.py ├── conftest.py ├── deploy ├── README.md └── stack1 │ ├── README.md │ ├── docker-compose.yml │ └── revproxy │ ├── Dockerfile │ ├── conf.d │ └── default.conf │ └── index.html ├── docs └── static │ └── Flask_RESTplus_Example_API.png ├── flask_restplus_patched ├── __init__.py ├── _http.py ├── api.py ├── model.py ├── namespace.py ├── parameters.py ├── resource.py └── swagger.py ├── local_config.py.template ├── migrations ├── __init__.py ├── alembic.ini ├── env.py ├── initial_development_data.py ├── script.py.mako └── versions │ ├── 15f27bc43bd_.py │ ├── 2b5af066bb9_.py │ ├── 2e9d99288cd_.py │ ├── 357c2809db4_.py │ ├── 36954739c63_.py │ ├── 4754e1427ac_.py │ ├── 5e2954a2af18_refactored-auth-oauth2.py │ ├── 81ce4ac01c45_migrate_static_roles.py │ ├── 82184d7d1e88_altered-OAuth2Token-token_type-to-Enum.py │ ├── 8c8b2d23a5_.py │ └── beb065460c24_fixed-password-type.py ├── requirements.txt ├── tasks ├── __init__.py ├── app │ ├── __init__.py │ ├── _utils.py │ ├── db.py │ ├── db_templates │ │ └── flask │ │ │ ├── alembic.ini │ │ │ ├── alembic.ini.mako │ │ │ ├── env.py │ │ │ └── script.py.mako │ ├── dependencies.py │ ├── env.py │ ├── run.py │ ├── swagger.py │ └── users.py ├── requirements.txt └── utils.py └── tests ├── __init__.py ├── conftest.py ├── extensions ├── __init__.py ├── api │ ├── __init__.py │ └── test_api_versions_availability.py └── test_extensions_availability.py ├── modules ├── __init__.py ├── auth │ ├── __init__.py │ ├── conftest.py │ ├── resources │ │ ├── __init__.py │ │ ├── test_creating_oauth2client.py │ │ ├── test_general_access.py │ │ ├── test_getting_oauth2clients_info.py │ │ └── test_token.py │ └── test_login_manager_integration.py ├── teams │ ├── __init__.py │ ├── conftest.py │ ├── resources │ │ ├── __init__.py │ │ ├── test_general_access.py │ │ ├── test_getting_teams_info.py │ │ ├── test_modifying_teams.py │ │ └── test_options.py │ └── test_models.py └── users │ ├── __init__.py │ ├── conftest.py │ ├── resources │ ├── __init__.py │ ├── test_general_access.py │ ├── test_getting_users_info.py │ ├── test_modifying_users_info.py │ ├── test_options.py │ └── test_signup.py │ ├── test_models.py │ ├── test_permissions.py │ └── test_schemas.py ├── requirements.txt ├── test_app_creation.py ├── test_openapi_spec_validity.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=app 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | Dockerfile 3 | 4 | example.db 5 | 6 | .git* 7 | 8 | deploy/* 9 | 10 | __pycache__ 11 | */__pycache__ 12 | */*/__pycache__ 13 | */*/*/__pycache__ 14 | */*/*/*/__pycache__ 15 | */*/*/*/*/__pycache__ 16 | 17 | *.pyc 18 | */*.pyc 19 | */*/*.pyc 20 | */*/*/*.pyc 21 | */*/*/*/*.pyc 22 | */*/*/*/*/*.pyc 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 4 8 | charset = utf-8 9 | 10 | [*.{json,yml,yaml}] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | /env/ 11 | /build/ 12 | *.egg-info/ 13 | .installed.cfg 14 | *.egg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | pip-delete-this-directory.txt 19 | 20 | # Unit test / coverage reports 21 | htmlcov/ 22 | .tox/ 23 | .coverage 24 | ,cover 25 | .cache 26 | nosetests.xml 27 | coverage.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | 37 | # Rope 38 | .ropeproject 39 | 40 | # Django stuff: 41 | *.log 42 | *.pot 43 | 44 | # Sphinx documentation 45 | docs/_build/ 46 | 47 | # Project specific ignores 48 | *.swp 49 | *.bak 50 | local_config.py 51 | static/ 52 | example.db 53 | .idea/ 54 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | - "nightly" 9 | - "pypy" 10 | 11 | matrix: 12 | allow_failures: 13 | - python: "nightly" 14 | - python: "pypy" 15 | 16 | branches: 17 | only: 18 | - master 19 | 20 | install: 21 | # Travis has pypy 2.5.0, which is way too old, so we upgrade it on the fly: 22 | - | 23 | if [ "$TRAVIS_PYTHON_VERSION" = "pypy" ]; then 24 | export PYENV_ROOT="$HOME/.pyenv" 25 | if [ -f "$PYENV_ROOT/bin/pyenv" ]; then 26 | pushd "$PYENV_ROOT" && git pull && popd 27 | else 28 | rm -rf "$PYENV_ROOT" && git clone --depth 1 https://github.com/yyuu/pyenv.git "$PYENV_ROOT" 29 | fi 30 | export PYPY_VERSION="5.6.0" 31 | "$PYENV_ROOT/bin/pyenv" install --skip-existing "pypy-$PYPY_VERSION" 32 | virtualenv --python="$PYENV_ROOT/versions/pypy-$PYPY_VERSION/bin/python" "$HOME/virtualenvs/pypy-$PYPY_VERSION" 33 | source "$HOME/virtualenvs/pypy-$PYPY_VERSION/bin/activate" 34 | fi 35 | - travis_retry pip install pylint 36 | - travis_retry pip install -r app/requirements.txt 37 | - travis_retry pip install -r tests/requirements.txt 38 | - travis_retry pip install pytest-cov coverage coveralls codacy-coverage 39 | 40 | cache: 41 | directories: 42 | - $HOME/.cache/pip 43 | - $HOME/.pyenv 44 | 45 | script: 46 | py.test --cov=app 47 | 48 | after_success: 49 | - pylint app 50 | - coveralls 51 | - coverage xml && python-codacy-coverage -r coverage.xml 52 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM frolvlad/alpine-python3 2 | 3 | ENV API_SERVER_HOME=/opt/www 4 | WORKDIR "$API_SERVER_HOME" 5 | COPY "./requirements.txt" "./" 6 | COPY "./app/requirements.txt" "./app/" 7 | COPY "./config.py" "./" 8 | COPY "./tasks" "./tasks" 9 | 10 | RUN apk add --no-cache --virtual=.build_dependencies musl-dev gcc python3-dev libffi-dev && \ 11 | cd /opt/www && \ 12 | pip install -r tasks/requirements.txt && \ 13 | invoke app.dependencies.install && \ 14 | rm -rf ~/.cache/pip && \ 15 | apk del .build_dependencies 16 | 17 | COPY "./" "./" 18 | 19 | RUN chown -R nobody "." && \ 20 | if [ ! -e "./local_config.py" ]; then \ 21 | cp "./local_config.py.template" "./local_config.py" ; \ 22 | fi 23 | 24 | USER nobody 25 | CMD [ "invoke", "app.run", "--no-install-dependencies", "--host", "0.0.0.0" ] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Vlad Frolov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: invoke app.dependencies.install_swagger_ui app.run --no-install-dependencies --host 0.0.0.0 --port $PORT --flask-config development 2 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Example RESTful API Server. 4 | """ 5 | import logging 6 | import os 7 | import sys 8 | 9 | from flask import Flask 10 | 11 | 12 | CONFIG_NAME_MAPPER = { 13 | 'development': 'config.DevelopmentConfig', 14 | 'testing': 'config.TestingConfig', 15 | 'production': 'config.ProductionConfig', 16 | 'local': 'local_config.LocalConfig', 17 | } 18 | 19 | def create_app(flask_config_name=None, **kwargs): 20 | """ 21 | Entry point to the Flask RESTful Server application. 22 | """ 23 | app = Flask(__name__, **kwargs) 24 | 25 | env_flask_config_name = os.getenv('FLASK_CONFIG') 26 | if not env_flask_config_name and flask_config_name is None: 27 | flask_config_name = 'local' 28 | elif flask_config_name is None: 29 | flask_config_name = env_flask_config_name 30 | else: 31 | if env_flask_config_name: 32 | assert env_flask_config_name == flask_config_name, ( 33 | "FLASK_CONFIG environment variable (\"%s\") and flask_config_name argument " 34 | "(\"%s\") are both set and are not the same." % ( 35 | env_flask_config_name, 36 | flask_config_name 37 | ) 38 | ) 39 | 40 | try: 41 | app.config.from_object(CONFIG_NAME_MAPPER[flask_config_name]) 42 | except ImportError: 43 | if flask_config_name == 'local': 44 | app.logger.error( 45 | "You have to have `local_config.py` or `local_config/__init__.py` in order to use " 46 | "the default 'local' Flask Config. Alternatively, you may set `FLASK_CONFIG` " 47 | "environment variable to one of the following options: development, production, " 48 | "testing." 49 | ) 50 | sys.exit(1) 51 | raise 52 | 53 | if app.debug: 54 | logging.getLogger('flask_oauthlib').setLevel(logging.DEBUG) 55 | app.logger.setLevel(logging.DEBUG) 56 | 57 | from . import extensions 58 | extensions.init_app(app) 59 | 60 | from . import modules 61 | modules.init_app(app) 62 | 63 | return app 64 | -------------------------------------------------------------------------------- /app/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=invalid-name,wrong-import-position 3 | """ 4 | Extensions setup 5 | ================ 6 | 7 | Extensions provide access to common resources of the application. 8 | 9 | Please, put new extension instantiations and initializations here. 10 | """ 11 | 12 | 13 | from flask_cors import CORS 14 | cross_origin_resource_sharing = CORS() 15 | 16 | #from sqlalchemy_utils import force_auto_coercion, force_instant_defaults 17 | #force_auto_coercion() 18 | #force_instant_defaults() 19 | 20 | from flask_mongoengine import MongoEngine 21 | db = MongoEngine() # connect MongoEngine with Flask App 22 | 23 | #from flask_sqlalchemy import SQLAlchemy 24 | #db = SQLAlchemy(session_options={'autocommit': True}) 25 | 26 | from flask_login import LoginManager 27 | login_manager = LoginManager() 28 | 29 | from flask_marshmallow import Marshmallow 30 | marshmallow = Marshmallow() 31 | 32 | from .auth import OAuth2Provider 33 | oauth2 = OAuth2Provider() 34 | 35 | from . import api 36 | 37 | 38 | class AlembicDatabaseMigrationConfig(object): 39 | """ 40 | Helper config holder that provides missing functions of Flask-Alembic 41 | package since we use custom invoke tasks instead. 42 | """ 43 | 44 | def __init__(self, database, directory='migrations', **kwargs): 45 | self.db = database 46 | self.directory = directory 47 | self.configure_args = kwargs 48 | 49 | 50 | def init_app(app): 51 | """ 52 | Application extensions initialization. 53 | """ 54 | for extension in ( 55 | cross_origin_resource_sharing, 56 | db, 57 | login_manager, 58 | marshmallow, 59 | api, 60 | oauth2, 61 | ): 62 | extension.init_app(app) 63 | 64 | app.extensions['migrate'] = AlembicDatabaseMigrationConfig(db, compare_type=True) 65 | 66 | 67 | -------------------------------------------------------------------------------- /app/extensions/api/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | API extension 4 | ============= 5 | """ 6 | 7 | from copy import deepcopy 8 | 9 | from flask import Blueprint, current_app 10 | 11 | from .api import Api 12 | from .namespace import Namespace 13 | from .http_exceptions import abort 14 | 15 | 16 | api_v1 = Api( # pylint: disable=invalid-name 17 | version='1.0', 18 | title="Flask-RESTplus Example API", 19 | description=( 20 | "It is a [real-life example RESTful API server implementation using Flask-RESTplus]" 21 | "(https://github.com/frol/flask-restplus-server-example).\n\n" 22 | "This demo features:\n" 23 | "* Self-documented RESTful API server using autogenerated OpenAPI specifications;\n" 24 | "* OAuth2 Password Flow (Resource Owner Password Credentials Grant) support;\n" 25 | "* Role-based permission system (it is also auto-documented);\n" 26 | "* PATCH method handled accordingly to RFC 6902;\n" 27 | "* 95+% code coverage.\n\n" 28 | "## Explore the demo\n\n" 29 | "I suggest you start with signing up a new user. To do so, use `POST /users/` endpoint " 30 | "with `recaptcha_key=\"secret_key\"`.\n\n" 31 | "You will need to know the API Client ID to authenticate, so here it is: " 32 | "`documentation`. Sometimes (e.g. for token refreshing) you might need API " 33 | "Client Secret: `KQ()SWK)SQK)QWSKQW(SKQ)S(QWSQW(SJ*HQ&HQW*SQ*^SSQWSGQSG`.\n\n" 34 | "There are also two built-in users:\n" 35 | "* `root` (administrator with all permissions) with password `q`\n" 36 | "* `user` (regular user) with password `w`\n" 37 | ), 38 | ) 39 | 40 | 41 | def serve_swaggerui_assets(path): 42 | """ 43 | Swagger-UI assets serving route. 44 | """ 45 | if not current_app.debug: 46 | import warnings 47 | warnings.warn( 48 | "/swaggerui/ is recommended to be served by public-facing server (e.g. NGINX)" 49 | ) 50 | from flask import send_from_directory 51 | return send_from_directory('../static/', path) 52 | 53 | 54 | def init_app(app, **kwargs): 55 | # pylint: disable=unused-argument 56 | """ 57 | API extension initialization point. 58 | """ 59 | app.route('/swaggerui/')(serve_swaggerui_assets) 60 | 61 | # Prevent config variable modification with runtime changes 62 | api_v1.authorizations = deepcopy(app.config['AUTHORIZATIONS']) 63 | -------------------------------------------------------------------------------- /app/extensions/api/api.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Extended Api implementation with an application-specific helpers 4 | ---------------------------------------------------------------- 5 | """ 6 | from six import iteritems 7 | 8 | from flask_restplus_patched import Api as BaseApi 9 | 10 | from .namespace import Namespace 11 | 12 | 13 | class Api(BaseApi): 14 | """ 15 | Having app-specific handlers here. 16 | """ 17 | 18 | def namespace(self, *args, **kwargs): 19 | # The only purpose of this method is to pass custom Namespace class 20 | _namespace = Namespace(*args, **kwargs) 21 | self.namespaces.append(_namespace) 22 | return _namespace 23 | 24 | def add_oauth_scope(self, scope_name, scope_description): 25 | for authorization_settings in self.authorizations.values(): 26 | if authorization_settings['type'].startswith('oauth'): 27 | assert scope_name not in authorization_settings['scopes'], \ 28 | "OAuth scope %s already exists" % scope_name 29 | authorization_settings['scopes'][scope_name] = scope_description 30 | 31 | def add_namespace(self, ns): 32 | # Rewrite security rules for OAuth scopes since Namespaces don't have 33 | # enough information about authorization methods. 34 | for resource, _, _ in ns.resources: 35 | for method in resource.methods: 36 | method_func = getattr(resource, method.lower()) 37 | 38 | if ( 39 | hasattr(method_func, '__apidoc__') 40 | and 41 | 'security' in method_func.__apidoc__ 42 | and 43 | '__oauth__' in method_func.__apidoc__['security'] 44 | ): 45 | oauth_scopes = method_func.__apidoc__['security']['__oauth__']['scopes'] 46 | method_func.__apidoc__['security'] = { 47 | auth_name: oauth_scopes 48 | for auth_name, auth_settings in iteritems(self.authorizations) 49 | if auth_settings['type'].startswith('oauth') 50 | } 51 | 52 | super(Api, self).add_namespace(ns) 53 | -------------------------------------------------------------------------------- /app/extensions/api/http_exceptions.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | HTTP exceptions collection 4 | -------------------------- 5 | """ 6 | 7 | from flask_restplus.errors import abort as restplus_abort 8 | from flask_restplus_patched._http import HTTPStatus 9 | 10 | 11 | API_DEFAULT_HTTP_CODE_MESSAGES = { 12 | HTTPStatus.UNAUTHORIZED.value: ( 13 | "The server could not verify that you are authorized to access the " 14 | "URL requested. You either supplied the wrong credentials (e.g. a bad " 15 | "password), or your browser doesn't understand how to supply the " 16 | "credentials required." 17 | ), 18 | HTTPStatus.FORBIDDEN.value: ( 19 | "You don't have the permission to access the requested resource." 20 | ), 21 | HTTPStatus.UNPROCESSABLE_ENTITY.value: ( 22 | "The request was well-formed but was unable to be followed due to semantic errors." 23 | ), 24 | } 25 | 26 | 27 | def abort(code, message=None, **kwargs): 28 | if message is None: 29 | if code in API_DEFAULT_HTTP_CODE_MESSAGES: 30 | message = API_DEFAULT_HTTP_CODE_MESSAGES[code] 31 | else: 32 | message = HTTPStatus(code).description 33 | restplus_abort(code=code, status=code, message=message, **kwargs) 34 | -------------------------------------------------------------------------------- /app/extensions/api/parameters.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Common reusable Parameters classes 4 | ---------------------------------- 5 | """ 6 | 7 | from marshmallow import validate 8 | 9 | from flask_marshmallow import base_fields 10 | from flask_restplus_patched import Parameters 11 | 12 | 13 | class PaginationParameters(Parameters): 14 | """ 15 | Helper Parameters class to reuse pagination. 16 | """ 17 | 18 | limit = base_fields.Integer( 19 | description="limit a number of items (allowed range is 1-100), default is 20.", 20 | missing=20, 21 | validate=validate.Range(min=1, max=100) 22 | ) 23 | offset = base_fields.Integer( 24 | description="a number of items to skip, default is 0.", 25 | missing=0, 26 | validate=validate.Range(min=0) 27 | ) 28 | -------------------------------------------------------------------------------- /app/extensions/api/webargs_parser.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Webargs Parser wrapper module 4 | ----------------------------- 5 | """ 6 | from webargs.flaskparser import FlaskParser 7 | 8 | from .http_exceptions import abort 9 | 10 | 11 | class CustomWebargsParser(FlaskParser): 12 | """ 13 | This custom Webargs Parser aims to overload :meth:``handle_error`` in order 14 | to call our custom :func:``abort`` function. 15 | 16 | See the following issue and the reated PR for more details: 17 | https://github.com/sloria/webargs/issues/122 18 | """ 19 | 20 | def handle_error(self, error): 21 | """ 22 | Handles errors during parsing. Aborts the current HTTP request and 23 | responds with a 422 error. 24 | """ 25 | status_code = getattr(error, 'status_code', self.DEFAULT_VALIDATION_STATUS) 26 | abort(status_code, messages=error.messages) 27 | -------------------------------------------------------------------------------- /app/extensions/auth/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Auth extension 4 | ============== 5 | """ 6 | 7 | from .oauth2 import OAuth2Provider 8 | -------------------------------------------------------------------------------- /app/extensions/auth/oauth2.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=no-self-use 3 | """ 4 | OAuth2 provider setup. 5 | 6 | It is based on the code from the example: 7 | https://github.com/lepture/example-oauth2-server 8 | 9 | More details are available here: 10 | * http://flask-oauthlib.readthedocs.org/en/latest/oauth2.html 11 | * http://lepture.com/en/2013/create-oauth-server 12 | """ 13 | 14 | from datetime import datetime, timedelta 15 | import logging 16 | 17 | from flask_login import current_user 18 | from flask_oauthlib import provider 19 | from flask_restplus_patched._http import HTTPStatus 20 | #import sqlalchemy 21 | 22 | from app.extensions import api, db 23 | 24 | 25 | log = logging.getLogger(__name__) 26 | 27 | 28 | class OAuth2RequestValidator(provider.OAuth2RequestValidator): 29 | """ 30 | A project-specific implementation of OAuth2RequestValidator, which connects 31 | our User and OAuth2* implementations together. 32 | """ 33 | 34 | def __init__(self): 35 | from app.modules.auth.models import OAuth2Client, OAuth2Grant, OAuth2Token 36 | self._client_class = OAuth2Client 37 | self._grant_class = OAuth2Grant 38 | self._token_class = OAuth2Token 39 | super(OAuth2RequestValidator, self).__init__( 40 | usergetter=self._usergetter, 41 | clientgetter=self._client_class.find, 42 | tokengetter=self._token_class.find, 43 | grantgetter=self._grant_class.find, 44 | tokensetter=self._tokensetter, 45 | grantsetter=self._grantsetter, 46 | ) 47 | 48 | def _usergetter(self, username, password, client, request): 49 | # pylint: disable=method-hidden,unused-argument 50 | # Avoid circular dependencies 51 | from app.modules.users.models import User 52 | return User.find_with_password(username, password) 53 | 54 | def _tokensetter(self, token, request, *args, **kwargs): 55 | # pylint: disable=method-hidden,unused-argument 56 | # TODO: review expiration time 57 | expires_in = token.pop('expires_in') 58 | expires = datetime.utcnow() + timedelta(seconds=expires_in) 59 | 60 | try: 61 | #with db.session.begin(): 62 | token_instance = self._token_class( 63 | access_token=token['access_token'], 64 | refresh_token=token.get('refresh_token'), 65 | token_type=token['token_type'], 66 | scopes=[scope for scope in token['scope'].split(' ') if scope], 67 | expires=expires, 68 | client_id=request.client.client_id, 69 | user_id=request.user.id, 70 | ) 71 | token_instance.save() 72 | #db.session.add(token_instance) 73 | #except sqlalchemy.exc.IntegrityError: 74 | except Exception as exception: 75 | log.exception("Token-setter has failed.") 76 | return None 77 | return token_instance 78 | 79 | def _grantsetter(self, client_id, code, request, *args, **kwargs): 80 | # pylint: disable=method-hidden,unused-argument 81 | # TODO: review expiration time 82 | # decide the expires time yourself 83 | expires = datetime.utcnow() + timedelta(seconds=100) 84 | try: 85 | #with db.session.begin(): 86 | grant_instance = self._grant_class( 87 | client_id=client_id, 88 | code=code['code'], 89 | redirect_uri=request.redirect_uri, 90 | scopes=request.scopes, 91 | user=current_user, 92 | expires=expires 93 | ) 94 | grant_instance.save() 95 | #db.session.add(grant_instance) 96 | #except sqlalchemy.exc.IntegrityError: 97 | except Exception as exception: 98 | log.exception("Grant-setter has failed.") 99 | return None 100 | return grant_instance 101 | 102 | 103 | def api_invalid_response(req): 104 | """ 105 | This is a default handler for OAuth2Provider, which raises abort exception 106 | with error message in JSON format. 107 | """ 108 | # pylint: disable=unused-argument 109 | api.abort(code=HTTPStatus.UNAUTHORIZED.value) 110 | 111 | 112 | class OAuth2Provider(provider.OAuth2Provider): 113 | """ 114 | A helper class which connects OAuth2RequestValidator with OAuth2Provider. 115 | """ 116 | 117 | def __init__(self, *args, **kwargs): 118 | super(OAuth2Provider, self).__init__(*args, **kwargs) 119 | self.invalid_response(api_invalid_response) 120 | 121 | def init_app(self, app): 122 | super(OAuth2Provider, self).init_app(app) 123 | self._validator = OAuth2RequestValidator() 124 | -------------------------------------------------------------------------------- /app/modules/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Modules 4 | ======= 5 | 6 | Modules enable logical resource separation. 7 | 8 | You may control enabled modules by modifying ``ENABLED_MODULES`` config 9 | variable. 10 | """ 11 | 12 | 13 | def init_app(app, **kwargs): 14 | from importlib import import_module 15 | 16 | for module_name in app.config['ENABLED_MODULES']: 17 | import_module('.%s' % module_name, package=__name__).init_app(app, **kwargs) 18 | -------------------------------------------------------------------------------- /app/modules/api/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Flask-RESTplus API registration module 4 | ====================================== 5 | """ 6 | 7 | from flask import Blueprint 8 | 9 | from app.extensions import api 10 | 11 | 12 | def init_app(app, **kwargs): 13 | # pylint: disable=unused-argument 14 | api_v1_blueprint = Blueprint('api', __name__, url_prefix='/api/v1') 15 | api.api_v1.init_app(api_v1_blueprint) 16 | app.register_blueprint(api_v1_blueprint) 17 | -------------------------------------------------------------------------------- /app/modules/api/mongo_helpers.py: -------------------------------------------------------------------------------- 1 | 2 | import datetime 3 | from flask_mongoengine import Document 4 | from mongoengine.fields import IntField, StringField, DateTimeField 5 | 6 | class Timestamp(object): 7 | """ 8 | Timestamp replacement for MongoDB 9 | """ 10 | updated = DateTimeField(default=datetime.datetime.utcnow()) 11 | 12 | @property 13 | def created(self): 14 | return self._created 15 | 16 | 17 | class EnumField(object): 18 | """ 19 | A class to register Enum type (from the package enum34) into mongo 20 | :param choices: must be of :class:`enum.Enum`: type 21 | and will be used as possible choices 22 | """ 23 | 24 | def __init__(self, enum, *args, **kwargs): 25 | self.enum = enum 26 | kwargs['choices'] = [choice for choice in enum] 27 | super(EnumField, self).__init__(*args, **kwargs) 28 | 29 | def __get_value(self, enum): 30 | return enum.value if hasattr(enum, 'value') else enum 31 | 32 | def to_python(self, value): 33 | return self.enum(super(EnumField, self).to_python(value)) 34 | 35 | def to_mongo(self, value): 36 | return self.__get_value(value) 37 | 38 | def prepare_query_value(self, op, value): 39 | return super(EnumField, self).prepare_query_value( 40 | op, self.__get_value(value)) 41 | 42 | def validate(self, value): 43 | return super(EnumField, self).validate(self.__get_value(value)) 44 | 45 | def _validate(self, value, **kwargs): 46 | return super(EnumField, self)._validate( 47 | self.enum(self.__get_value(value)), **kwargs) 48 | 49 | 50 | class IntEnumField(EnumField, IntField): 51 | """A variation on :class:`EnumField` for only int containing enumeration. 52 | """ 53 | pass 54 | 55 | 56 | class StringEnumField(EnumField, StringField): 57 | """A variation on :class:`EnumField` for only string containing enumeration. 58 | """ 59 | pass 60 | -------------------------------------------------------------------------------- /app/modules/auth/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Auth module 4 | =========== 5 | """ 6 | from app.extensions import login_manager, oauth2 7 | from app.extensions.api import api_v1 8 | 9 | 10 | def load_user_from_request(request): 11 | """ 12 | Load user from OAuth2 Authentication header. 13 | """ 14 | user = None 15 | if hasattr(request, 'oauth'): 16 | user = request.oauth.user 17 | else: 18 | is_valid, oauth = oauth2.verify_request(scopes=[]) 19 | if is_valid: 20 | user = oauth.user 21 | return user 22 | 23 | def init_app(app, **kwargs): 24 | # pylint: disable=unused-argument 25 | """ 26 | Init auth module. 27 | """ 28 | # Bind Flask-Login for current_user 29 | login_manager.request_loader(load_user_from_request) 30 | 31 | # Register OAuth scopes 32 | api_v1.add_oauth_scope('auth:read', "Provide access to auth details") 33 | api_v1.add_oauth_scope('auth:write', "Provide write access to auth details") 34 | 35 | # Touch underlying modules 36 | from . import models, views, resources 37 | 38 | # Mount authentication routes 39 | app.register_blueprint(views.auth_blueprint) 40 | api_v1.add_namespace(resources.api) 41 | -------------------------------------------------------------------------------- /app/modules/auth/models.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | OAuth2 provider models. 4 | 5 | It is based on the code from the example: 6 | https://github.com/lepture/example-oauth2-server 7 | 8 | More details are available here: 9 | * http://flask-oauthlib.readthedocs.org/en/latest/oauth2.html 10 | * http://lepture.com/en/2013/create-oauth-server 11 | """ 12 | import enum 13 | 14 | #from sqlalchemy_utils.types import ScalarListType 15 | 16 | from app.extensions import db 17 | from flask_mongoengine import Document 18 | from app.modules.users.models import User 19 | from mongoengine import EmailField, StringField, DateTimeField, IntField, ListField, ReferenceField 20 | from app.modules.api.mongo_helpers import EnumField, StringEnumField 21 | 22 | 23 | class OAuth2Client(Document): 24 | #class OAuth2Client(db.Model): 25 | """ 26 | Model that binds OAuth2 Client ID and Secret to a specific User. 27 | """ 28 | 29 | meta = {'allow_inheritance': True, 'abstract': False, 'collection': 'oauth2_client'} 30 | 31 | #__tablename__ = 'oauth2_client' 32 | 33 | client_id = StringField(max_length=40) 34 | #client_id = db.Column(db.String(length=40), primary_key=True) 35 | client_secret = StringField(max_length=55) 36 | #client_secret = db.Column(db.String(length=55), nullable=False) 37 | 38 | user = ReferenceField("User") 39 | @property 40 | def user_id(self): 41 | return self.user.id 42 | 43 | #user_id = db.Column(db.ForeignKey('user.id', ondelete='CASCADE'), index=True, nullable=False) 44 | #user = db.relationship('User') 45 | 46 | #class ClientTypes(str, enum.Enum): 47 | # public = 'public' 48 | # confidential = 'confidential' 49 | 50 | client_type = StringEnumField(['public','confidential'], default='public') 51 | 52 | #client_type = db.Column(db.Enum(ClientTypes), default=ClientTypes.public, nullable=False) 53 | #redirect_uris = db.Column(ScalarListType(separator=' '), default=[], nullable=False) 54 | #default_scopes = db.Column(ScalarListType(separator=' '), nullable=False) 55 | 56 | redirect_uris = ListField(StringField(max_length=30), default=[]) 57 | default_scopes = ListField(StringField(max_length=30), default=[]) 58 | 59 | 60 | @property 61 | def default_redirect_uri(self): 62 | redirect_uris = self.redirect_uris 63 | if redirect_uris: 64 | return redirect_uris[0] 65 | return None 66 | 67 | @classmethod 68 | def find(cls, client_id): 69 | if not client_id: 70 | return 71 | return cls.objects(client_id=client_id).first() 72 | 73 | #return cls.query.get(client_id) 74 | 75 | 76 | #class OAuth2Grant(db.Model): 77 | class OAuth2Grant(Document): 78 | 79 | """ 80 | Intermediate temporary helper for OAuth2 Grants. 81 | """ 82 | 83 | meta = {'allow_inheritance': True, 'abstract': False, 'collection': 'oauth2_grant'} 84 | #__tablename__ = 'oauth2_grant' 85 | 86 | #id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 87 | id = IntField() 88 | 89 | user = ReferenceField("User") 90 | @property 91 | def user_id(self): 92 | return self.user.id 93 | 94 | #user_id = db.Column(db.ForeignKey('user.id', ondelete='CASCADE'), index=True, nullable=False) 95 | #user = db.relationship('User') 96 | 97 | client = ReferenceField("OAuth2Client") 98 | @property 99 | def client_id(self): 100 | return self.client.client_id 101 | 102 | #client_id = db.Column( 103 | # db.String(length=40), 104 | # db.ForeignKey('oauth2_client.client_id'), 105 | # index=True, 106 | # nullable=False, 107 | #) 108 | #client = db.relationship('OAuth2Client') 109 | 110 | code = StringField(max_length=255) 111 | 112 | #code = db.Column(db.String(length=255), index=True, nullable=False) 113 | 114 | #redirect_uri = db.Column(db.String(length=255), nullable=False) 115 | #expires = db.Column(db.DateTime, nullable=False) 116 | redirect_uri = StringField(max_length=255) 117 | expires = DateTimeField() 118 | 119 | #scopes = db.Column(ScalarListType(separator=' '), nullable=False) 120 | scopes = ListField(StringField(max_length=30), default=[]) 121 | 122 | " mongoengine provides delete of same name, does not return self " 123 | #def delete(self): 124 | # db.session.delete(self) 125 | # db.session.commit() 126 | # return self 127 | 128 | @classmethod 129 | def find(cls, client_id, code): 130 | return cls.objects(client_id=client_id, code=code).first() 131 | #return cls.query.filter_by(client_id=client_id, code=code).first() 132 | 133 | 134 | #class OAuth2Token(db.Model): 135 | class OAuth2Token(Document): 136 | """ 137 | OAuth2 Access Tokens storage model. 138 | """ 139 | 140 | meta = {'allow_inheritance': True, 'abstract': False, 'collection': 'oauth2_token'} 141 | #__tablename__ = 'oauth2_token' 142 | 143 | id = IntField() 144 | #id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 145 | 146 | client = ReferenceField("OAuth2Client") 147 | @property 148 | def client_id(self): 149 | return self.client.client_id 150 | 151 | #client_id = db.Column( 152 | # db.String(length=40), 153 | # db.ForeignKey('oauth2_client.client_id'), 154 | # index=True, 155 | # nullable=False, 156 | #) 157 | #client = db.relationship('OAuth2Client') 158 | 159 | user = ReferenceField("User") 160 | @property 161 | def user_id(self): 162 | return self.user.id 163 | 164 | #user_id = db.Column(db.ForeignKey('user.id', ondelete='CASCADE'), index=True, nullable=False) 165 | #user = db.relationship('User') 166 | 167 | token_type = StringEnumField(['Bearer'], default='Bearer') 168 | 169 | #class TokenTypes(str, enum.Enum): 170 | # # currently only bearer is supported 171 | # Bearer = 'Bearer' 172 | #token_type = db.Column(db.Enum(TokenTypes), nullable=False) 173 | 174 | access_token = StringField(max_length=255, unique=True) 175 | refresh_token = StringField(max_length=255, unique=True) 176 | expires = DateTimeField() 177 | scopes = ListField(StringField(max_length=30), default=[]) 178 | 179 | #access_token = db.Column(db.String(length=255), unique=True, nullable=False) 180 | #refresh_token = db.Column(db.String(length=255), unique=True, nullable=True) 181 | #expires = db.Column(db.DateTime, nullable=False) 182 | #scopes = db.Column(ScalarListType(separator=' '), nullable=False) 183 | 184 | @classmethod 185 | def find(cls, access_token=None, refresh_token=None): 186 | if access_token: 187 | return cls.objects(access_token=access_token).first() 188 | #return cls.query.filter_by(access_token=access_token).first() 189 | elif refresh_token: 190 | return cls.objects(refresh_token=refresh_token).first() 191 | #return cls.query.filter_by(refresh_token=refresh_token).first() 192 | -------------------------------------------------------------------------------- /app/modules/auth/parameters.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=wrong-import-order 3 | """ 4 | Input arguments (Parameters) for Auth resources RESTful API 5 | ----------------------------------------------------------- 6 | """ 7 | from flask_login import current_user 8 | from flask_marshmallow import base_fields 9 | from flask_restplus_patched import PostFormParameters 10 | from marshmallow import validates, ValidationError 11 | 12 | from app.extensions import api 13 | from app.extensions.api.parameters import PaginationParameters 14 | 15 | 16 | class ListOAuth2ClientsParameters(PaginationParameters): 17 | user_id = base_fields.Integer(required=True) 18 | 19 | @validates('user_id') 20 | def validate_user_id(self, data): 21 | if current_user.id != data: 22 | raise ValidationError("It is only allowed to query your own OAuth2 clients.") 23 | 24 | 25 | class CreateOAuth2ClientParameters(PostFormParameters): 26 | redirect_uris = base_fields.List(base_fields.String, required=False) 27 | default_scopes = base_fields.List(base_fields.String, required=True) 28 | 29 | @validates('default_scopes') 30 | def validate_default_scopes(self, data): 31 | unknown_scopes = set(data) - set(api.api_v1.authorizations['oauth2_password']['scopes']) 32 | if unknown_scopes: 33 | raise ValidationError("'%s' scope(s) are not supported." % (', '.join(unknown_scopes))) 34 | -------------------------------------------------------------------------------- /app/modules/auth/resources.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-few-public-methods,invalid-name,bad-continuation 3 | """ 4 | RESTful API Auth resources 5 | -------------------------- 6 | """ 7 | 8 | import logging 9 | 10 | from flask_login import current_user 11 | from flask_restplus_patched import Resource 12 | from flask_restplus_patched._http import HTTPStatus 13 | from werkzeug import security 14 | 15 | from app.extensions.api import Namespace 16 | 17 | from . import schemas, parameters 18 | from .models import db, OAuth2Client 19 | 20 | 21 | log = logging.getLogger(__name__) 22 | api = Namespace('auth', description="Authentication") 23 | 24 | 25 | @api.route('/oauth2_clients/') 26 | @api.login_required(oauth_scopes=['auth:read']) 27 | class OAuth2Clients(Resource): 28 | """ 29 | Manipulations with OAuth2 clients. 30 | """ 31 | 32 | @api.parameters(parameters.ListOAuth2ClientsParameters()) 33 | @api.response(schemas.BaseOAuth2ClientSchema(many=True)) 34 | def get(self, args): 35 | """ 36 | List of OAuth2 Clients. 37 | 38 | Returns a list of OAuth2 Clients starting from ``offset`` limited by 39 | ``limit`` parameter. 40 | """ 41 | #oauth2_clients = OAuth2Client.query 42 | oauth2_clients = OAuth2Client.objects 43 | if 'user_id' in args: 44 | oauth2_clients = oauth2_clients( 45 | user_id = args['user_id'] 46 | ) 47 | #oauth2_clients = oauth2_clients.filter( 48 | # OAuth2Client.user_id == args['user_id'] 49 | #) 50 | 51 | return oauth2_clients.skip(args['offset']).limit(args['limit']) 52 | #return oauth2_clients.offset(args['offset']).limit(args['limit']) 53 | 54 | 55 | @api.login_required(oauth_scopes=['auth:write']) 56 | @api.parameters(parameters.CreateOAuth2ClientParameters()) 57 | @api.response(schemas.DetailedOAuth2ClientSchema()) 58 | @api.response(code=HTTPStatus.FORBIDDEN) 59 | @api.response(code=HTTPStatus.CONFLICT) 60 | @api.doc(id='create_oauth_client') 61 | def post(self, args): 62 | """ 63 | Create a new OAuth2 Client. 64 | 65 | Essentially, OAuth2 Client is a ``client_id`` and ``client_secret`` 66 | pair associated with a user. 67 | """ 68 | with api.commit_or_abort( 69 | default_error_message="Failed to create a new OAuth2 client." 70 | ): 71 | # TODO: reconsider using gen_salt 72 | new_oauth2_client = OAuth2Client( 73 | user_id=current_user.id, 74 | client_id=security.gen_salt(40), 75 | client_secret=security.gen_salt(50), 76 | **args 77 | ) 78 | new_oauth2_client.save() 79 | #db.session.add(new_oauth2_client) 80 | return new_oauth2_client 81 | -------------------------------------------------------------------------------- /app/modules/auth/schemas.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-few-public-methods 3 | """ 4 | Auth schemas 5 | ------------ 6 | """ 7 | 8 | from flask_marshmallow import base_fields 9 | from flask_restplus_patched import ModelSchema 10 | 11 | from .models import OAuth2Client 12 | 13 | 14 | class BaseOAuth2ClientSchema(ModelSchema): 15 | """ 16 | Base OAuth2 client schema exposes only the most general fields. 17 | """ 18 | default_scopes = base_fields.List(base_fields.String, required=True) 19 | redirect_uris = base_fields.List(base_fields.String, required=True) 20 | 21 | #fields = ('full_name', 'date_created') 22 | 23 | class Meta: 24 | # pylint: disable=missing-docstring 25 | model = OAuth2Client 26 | fields = ( 27 | 'user_id', 28 | 'client_id', 29 | 'client_type', 30 | 'default_scopes', 31 | 'redirect_uris' 32 | ) 33 | dump_only = ( 34 | 'user_id', 35 | 'client_id', 36 | ) 37 | #fields = ( 38 | # OAuth2Client.user_id.key, 39 | # OAuth2Client.client_id.key, 40 | # OAuth2Client.client_type.key, 41 | # OAuth2Client.default_scopes.key, 42 | # OAuth2Client.redirect_uris.key, 43 | #) 44 | #dump_only = ( 45 | # OAuth2Client.user_id.key, 46 | # OAuth2Client.client_id.key, 47 | #) 48 | 49 | 50 | class DetailedOAuth2ClientSchema(BaseOAuth2ClientSchema): 51 | """ 52 | Detailed OAuth2 client schema exposes all useful fields. 53 | """ 54 | 55 | class Meta(BaseOAuth2ClientSchema.Meta): 56 | fields = BaseOAuth2ClientSchema.Meta.fields + ( 57 | 'client_secret', 58 | ) 59 | #fields = BaseOAuth2ClientSchema.Meta.fields + ( 60 | # OAuth2Client.client_secret.key, 61 | #) 62 | -------------------------------------------------------------------------------- /app/modules/auth/views.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | OAuth2 provider setup. 4 | 5 | It is based on the code from the example: 6 | https://github.com/lepture/example-oauth2-server 7 | 8 | More details are available here: 9 | * http://flask-oauthlib.readthedocs.org/en/latest/oauth2.html 10 | * http://lepture.com/en/2013/create-oauth-server 11 | """ 12 | 13 | from flask import Blueprint, request, render_template 14 | from flask_login import current_user 15 | from flask_restplus_patched._http import HTTPStatus 16 | 17 | from app.extensions import api, oauth2 18 | 19 | from .models import OAuth2Client 20 | 21 | 22 | auth_blueprint = Blueprint('auth', __name__, url_prefix='/auth') # pylint: disable=invalid-name 23 | 24 | 25 | @auth_blueprint.route('/oauth2/token', methods=['GET', 'POST']) 26 | @oauth2.token_handler 27 | def access_token(*args, **kwargs): 28 | # pylint: disable=unused-argument 29 | """ 30 | This endpoint is for exchanging/refreshing an access token. 31 | 32 | Returns: 33 | response (dict): a dictionary or None as the extra credentials for 34 | creating the token response. 35 | """ 36 | return None 37 | 38 | @auth_blueprint.route('/oauth2/revoke', methods=['POST']) 39 | @oauth2.revoke_handler 40 | def revoke_token(): 41 | """ 42 | This endpoint allows a user to revoke their access token. 43 | """ 44 | pass 45 | 46 | @auth_blueprint.route('/oauth2/authorize', methods=['GET', 'POST']) 47 | @oauth2.authorize_handler 48 | def authorize(*args, **kwargs): 49 | # pylint: disable=unused-argument 50 | """ 51 | This endpoint asks user if he grants access to his data to the requesting 52 | application. 53 | """ 54 | # TODO: improve implementation. This implementation is broken because we 55 | # don't use cookies, so there is no session which client could carry on. 56 | # OAuth2 server should probably be deployed on a separate domain, so we 57 | # can implement a login page and store cookies with a session id. 58 | # ALTERNATIVELY, authorize page can be implemented as SPA (single page 59 | # application) 60 | if not current_user.is_authenticated: 61 | return api.abort(code=HTTPStatus.UNAUTHORIZED) 62 | 63 | if request.method == 'GET': 64 | client_id = kwargs.get('client_id') 65 | oauth2_client = OAuth2Client.objects.get_or_404(client_id=client_id) 66 | kwargs['client'] = oauth2_client 67 | kwargs['user'] = current_user 68 | # TODO: improve template design 69 | return render_template('authorize.html', **kwargs) 70 | 71 | confirm = request.form.get('confirm', 'no') 72 | return confirm == 'yes' 73 | -------------------------------------------------------------------------------- /app/modules/teams/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Teams module 4 | ============ 5 | """ 6 | 7 | from app.extensions.api import api_v1 8 | 9 | 10 | def init_app(app, **kwargs): 11 | # pylint: disable=unused-argument,unused-variable 12 | """ 13 | Init teams module. 14 | """ 15 | api_v1.add_oauth_scope('teams:read', "Provide access to team details") 16 | api_v1.add_oauth_scope('teams:write', "Provide write access to team details") 17 | 18 | # Touch underlying modules 19 | from . import models, resources 20 | 21 | api_v1.add_namespace(resources.api) 22 | -------------------------------------------------------------------------------- /app/modules/teams/models.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Team database models 4 | -------------------- 5 | """ 6 | 7 | #from sqlalchemy_utils import Timestamp 8 | 9 | from app.extensions import db 10 | from mongoengine import EmailField, StringField, DateTimeField, IntField, ListField, ReferenceField, BooleanField 11 | from app.modules.api.mongo_helpers import EnumField, StringEnumField, Timestamp 12 | from flask_mongoengine import Document 13 | 14 | 15 | #class TeamMember(db.Model): 16 | class TeamMember(Document): 17 | 18 | """ 19 | Team-member database model. 20 | """ 21 | #__tablename__ = 'team_member' 22 | meta = {'allow_inheritance': True, 'abstract': False, 'collection': 'team_member'} 23 | 24 | 25 | team = ReferenceField("Team") 26 | @property 27 | def team_id(self): 28 | return self.team.id 29 | user = ReferenceField("User", unique_with='team') 30 | @property 31 | def user_id(self): 32 | return self.user.id 33 | 34 | 35 | #team_id = db.Column(db.Integer, db.ForeignKey('team.id'), primary_key=True) 36 | #team = db.relationship('Team') 37 | #user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) 38 | #user = db.relationship( 39 | # 'User', 40 | # backref=db.backref('teams_membership', cascade='delete, delete-orphan') 41 | #) 42 | 43 | is_leader = BooleanField(default=False) 44 | #is_leader = db.Column(db.Boolean, default=False, nullable=False) 45 | 46 | #__table_args__ = ( 47 | # db.UniqueConstraint('team_id', 'user_id', name='_team_user_uc'), 48 | #) 49 | 50 | def __repr__(self): 51 | return ( 52 | "<{class_name}(" 53 | "team_id={self.team_id}, " 54 | "user_id=\"{self.user_id}\", " 55 | "is_leader=\"{self.is_leader}\"" 56 | ")>".format( 57 | class_name=self.__class__.__name__, 58 | self=self 59 | ) 60 | ) 61 | 62 | def check_owner(self, user): 63 | return self.user == user 64 | 65 | def check_supervisor(self, user): 66 | return self.team.check_owner(user) 67 | 68 | 69 | #class Team(db.Model, Timestamp): 70 | class Team(Document, Timestamp): 71 | 72 | """ 73 | Team database model. 74 | """ 75 | 76 | id = IntField() 77 | title = StringField(max_length=50) 78 | 79 | #id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 80 | #title = db.Column(db.String(length=50), nullable=False) 81 | 82 | members = ListField(ReferenceField('TeamMember')) 83 | 84 | #members = db.relationship('TeamMember', cascade='delete, delete-orphan') 85 | 86 | def __repr__(self): 87 | return ( 88 | "<{class_name}(" 89 | "id={self.id}, " 90 | "title=\"{self.title}\"" 91 | ")>".format( 92 | class_name=self.__class__.__name__, 93 | self=self 94 | ) 95 | ) 96 | 97 | #FIXME: find mongoengine equivalent 98 | #@db.validates('title') 99 | def validate_title(self, key, title): # pylint: disable=unused-argument,no-self-use 100 | if len(title) < 3: 101 | raise ValueError("Title has to be at least 3 characters long.") 102 | return title 103 | 104 | def check_owner(self, user): 105 | """ 106 | This is a helper method for OwnerRolePermission integration. 107 | """ 108 | 109 | if self.objects(team=self, is_leader=True, user=user).first(): 110 | return True 111 | return False 112 | 113 | #if db.session.query( 114 | # TeamMember.query.filter_by(team=self, is_leader=True, user=user).exists() 115 | #).scalar(): 116 | # return True 117 | #return False 118 | -------------------------------------------------------------------------------- /app/modules/teams/parameters.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Input arguments (Parameters) for Team resources RESTful API 4 | ----------------------------------------------------------- 5 | """ 6 | 7 | from flask_marshmallow import base_fields 8 | from flask_restplus_patched import PostFormParameters, PatchJSONParameters 9 | 10 | from . import schemas 11 | from .models import Team 12 | 13 | 14 | class CreateTeamParameters(PostFormParameters, schemas.BaseTeamSchema): 15 | 16 | class Meta(schemas.BaseTeamSchema.Meta): 17 | # This is not supported yet: https://github.com/marshmallow-code/marshmallow/issues/344 18 | required = ( 19 | 'title', 20 | ) 21 | #required = ( 22 | # Team.title.key, 23 | #) 24 | 25 | 26 | class PatchTeamDetailsParameters(PatchJSONParameters): 27 | # pylint: disable=abstract-method,missing-docstring 28 | OPERATION_CHOICES = ( 29 | PatchJSONParameters.OP_REPLACE, 30 | ) 31 | 32 | PATH_CHOICES = tuple( 33 | '/%s' % field for field in ( 34 | Team.title.key, 35 | ) 36 | ) 37 | 38 | 39 | class AddTeamMemberParameters(PostFormParameters): 40 | user_id = base_fields.Integer(required=True) 41 | is_leader = base_fields.Boolean(required=False) 42 | -------------------------------------------------------------------------------- /app/modules/teams/schemas.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Serialization schemas for Team resources RESTful API 4 | ---------------------------------------------------- 5 | """ 6 | 7 | from flask_marshmallow import base_fields 8 | from flask_restplus_patched import ModelSchema 9 | 10 | from app.modules.users.schemas import BaseUserSchema 11 | 12 | from .models import Team, TeamMember 13 | 14 | 15 | class BaseTeamSchema(ModelSchema): 16 | """ 17 | Base team schema exposes only the most general fields. 18 | """ 19 | 20 | class Meta: 21 | # pylint: disable=missing-docstring 22 | model = Team 23 | fields = ( 24 | 'id', 25 | 'title', 26 | ) 27 | dump_only = ( 28 | 'id', 29 | ) 30 | #fields = ( 31 | # Team.id.key, 32 | # Team.title.key, 33 | #) 34 | #dump_only = ( 35 | # Team.id.key, 36 | #) 37 | 38 | 39 | class DetailedTeamSchema(BaseTeamSchema): 40 | """ 41 | Detailed team schema exposes all useful fields. 42 | """ 43 | 44 | members = base_fields.Nested( 45 | 'BaseTeamMemberSchema', 46 | exclude=(TeamMember.team, ), 47 | many=True 48 | ) 49 | #members = base_fields.Nested( 50 | # 'BaseTeamMemberSchema', 51 | # exclude=(TeamMember.team.key, ), 52 | # many=True 53 | #) 54 | 55 | class Meta(BaseTeamSchema.Meta): 56 | fields = BaseTeamSchema.Meta.fields + ( 57 | 'members', 58 | 'created', 59 | 'updated', 60 | ) 61 | #fields = BaseTeamSchema.Meta.fields + ( 62 | # Team.members.key, 63 | # Team.created.key, 64 | # Team.updated.key, 65 | #) 66 | 67 | 68 | class BaseTeamMemberSchema(ModelSchema): 69 | 70 | team = base_fields.Nested(BaseTeamSchema) 71 | user = base_fields.Nested(BaseUserSchema) 72 | 73 | class Meta: 74 | model = TeamMember 75 | fields = ( 76 | 'team', 77 | 'user', 78 | 'is_leader', 79 | ) 80 | #fields = ( 81 | # TeamMember.team.key, 82 | # TeamMember.user.key, 83 | # TeamMember.is_leader.key, 84 | #) 85 | -------------------------------------------------------------------------------- /app/modules/users/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Users module 4 | ============ 5 | """ 6 | 7 | from app.extensions.api import api_v1 8 | 9 | 10 | def init_app(app, **kwargs): 11 | # pylint: disable=unused-argument,unused-variable 12 | """ 13 | Init users module. 14 | """ 15 | api_v1.add_oauth_scope('users:read', "Provide access to user details") 16 | api_v1.add_oauth_scope('users:write', "Provide write access to user details") 17 | 18 | # Touch underlying modules 19 | from . import models, resources 20 | 21 | api_v1.add_namespace(resources.api) 22 | -------------------------------------------------------------------------------- /app/modules/users/models.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | User database models 4 | -------------------- 5 | """ 6 | import enum 7 | 8 | from flask_mongoengine import Document 9 | from mongoengine import EmailField, StringField, DateTimeField, IntField 10 | from datetime import datetime 11 | from app.modules.api.mongo_helpers import Timestamp 12 | 13 | 14 | from app.extensions import db 15 | 16 | 17 | def _get_is_static_role_property(role_name, static_role): 18 | """ 19 | A helper function that aims to provide a property getter and setter 20 | for static roles. 21 | 22 | Args: 23 | role_name (str) 24 | static_role (int) - a bit mask for a specific role 25 | 26 | Returns: 27 | property_method (property) - preconfigured getter and setter property 28 | for accessing role. 29 | """ 30 | @property 31 | def _is_static_role_property(self): 32 | return self.has_static_role(static_role) 33 | 34 | @_is_static_role_property.setter 35 | def _is_static_role_property(self, value): 36 | if value: 37 | self.set_static_role(static_role) 38 | else: 39 | self.unset_static_role(static_role) 40 | 41 | _is_static_role_property.fget.__name__ = role_name 42 | return _is_static_role_property 43 | 44 | 45 | class User(Document, Timestamp): 46 | meta = {'allow_inheritance': True, 'abstract': False, 'collection': 'users'} 47 | 48 | 49 | email = EmailField(unique=True) 50 | password = StringField(max_length=128) 51 | 52 | 53 | first_name = StringField(max_length=30) 54 | middle_name = StringField(max_length=30) 55 | last_name = StringField(max_length=30) 56 | 57 | class StaticRoles(enum.Enum): 58 | INTERNAL = (0x8000, "Internal") 59 | ADMIN = (0x4000, "Admin") 60 | REGULAR_USER = (0x2000, "Regular User") 61 | ACTIVE = (0x1000, "Active Account") 62 | 63 | @property 64 | def mask(self): 65 | return self.value[0] 66 | 67 | @property 68 | def title(self): 69 | return self.value[1] 70 | 71 | 72 | static_roles = IntField(default=0) 73 | 74 | is_internal = _get_is_static_role_property('is_internal', StaticRoles.INTERNAL) 75 | is_admin = _get_is_static_role_property('is_admin', StaticRoles.ADMIN) 76 | is_regular_user = _get_is_static_role_property('is_regular_user', StaticRoles.REGULAR_USER) 77 | is_active = _get_is_static_role_property('is_active', StaticRoles.ACTIVE) 78 | 79 | def __repr__(self): 80 | return ( 81 | "<{class_name}(" 82 | "id={self.id}, " 83 | "email=\"{self.email}\", " 84 | "is_internal={self.is_internal}, " 85 | "is_admin={self.is_admin}, " 86 | "is_regular_user={self.is_regular_user}, " 87 | "is_active={self.is_active}, " 88 | ")>".format( 89 | class_name=self.__class__.__name__, 90 | self=self 91 | ) 92 | ) 93 | 94 | def has_static_role(self, role): 95 | return (self['static_roles'] & role.mask) != 0 96 | 97 | def set_static_role(self, role): 98 | if self.has_static_role(role): 99 | return 100 | self.static_roles |= role.mask 101 | 102 | def unset_static_role(self, role): 103 | if not self.has_static_role(role): 104 | return 105 | self.static_roles ^= role.mask 106 | 107 | def check_owner(self, user): 108 | return self == user 109 | 110 | @property 111 | def is_authenticated(self): 112 | return True 113 | 114 | @property 115 | def is_anonymous(self): 116 | return False 117 | 118 | @classmethod 119 | def find_with_password(cls, email, password): 120 | """ 121 | Args: 122 | email (str) 123 | password (str) - plain-text password 124 | 125 | Returns: 126 | user (User) - if there is a user with a specified email and 127 | password, None otherwise. 128 | """ 129 | user = cls.objects(email=email).first() 130 | 131 | if not user: 132 | return None 133 | if user.password == password: 134 | return user 135 | return None 136 | 137 | 138 | -------------------------------------------------------------------------------- /app/modules/users/parameters.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=wrong-import-order 3 | """ 4 | Input arguments (Parameters) for User resources RESTful API 5 | ----------------------------------------------------------- 6 | """ 7 | 8 | from flask_login import current_user 9 | from flask_marshmallow import base_fields 10 | from flask_restplus_patched import PostFormParameters, PatchJSONParameters 11 | from flask_restplus_patched._http import HTTPStatus 12 | from marshmallow import validates_schema, ValidationError 13 | 14 | from app.extensions.api import abort 15 | 16 | from . import schemas, permissions 17 | from .models import User 18 | 19 | 20 | class AddUserParameters(PostFormParameters, schemas.BaseUserSchema): 21 | """ 22 | New user creation (sign up) parameters. 23 | """ 24 | 25 | username = base_fields.String(description="Example: root", required=True) 26 | email = base_fields.Email(description="Example: root@gmail.com", required=True) 27 | password = base_fields.String(description="No rules yet", required=True) 28 | recaptcha_key = base_fields.String( 29 | description=( 30 | "See `/users/signup_form` for details. It is required for everybody, except admins" 31 | ), 32 | required=False 33 | ) 34 | 35 | class Meta(schemas.BaseUserSchema.Meta): 36 | fields = schemas.BaseUserSchema.Meta.fields + ( 37 | 'email', 38 | 'password', 39 | 'recaptcha_key', 40 | ) 41 | 42 | @validates_schema 43 | def validate_captcha(self, data): 44 | """" 45 | Check reCAPTCHA if necessary. 46 | 47 | NOTE: we remove 'recaptcha_key' from data once checked because we don't need it 48 | in the resource 49 | """ 50 | recaptcha_key = data.pop('recaptcha_key', None) 51 | captcha_is_valid = False 52 | if not recaptcha_key: 53 | no_captcha_permission = permissions.AdminRolePermission() 54 | if no_captcha_permission.check(): 55 | captcha_is_valid = True 56 | # NOTE: This hardcoded CAPTCHA key is just for demo purposes. 57 | elif recaptcha_key == 'secret_key': 58 | captcha_is_valid = True 59 | 60 | if not captcha_is_valid: 61 | abort(code=HTTPStatus.FORBIDDEN, message="CAPTCHA key is incorrect.") 62 | 63 | 64 | class PatchUserDetailsParameters(PatchJSONParameters): 65 | # pylint: disable=abstract-method 66 | """ 67 | User details updating parameters following PATCH JSON RFC. 68 | """ 69 | 70 | PATH_CHOICES = tuple( 71 | '/%s' % field for field in ( 72 | 'current_password', 73 | User.first_name.db_field, 74 | User.middle_name.db_field, 75 | User.last_name.db_field, 76 | User.password.db_field, 77 | User.email.db_field, 78 | User.is_active.fget.__name__, 79 | User.is_regular_user.fget.__name__, 80 | User.is_admin.fget.__name__, 81 | ) 82 | ) 83 | #PATH_CHOICES = tuple( 84 | # '/%s' % field for field in ( 85 | # 'current_password', 86 | # User.first_name.key, 87 | # User.middle_name.key, 88 | # User.last_name.key, 89 | # User.password.key, 90 | # User.email.key, 91 | # User.is_active.fget.__name__, 92 | # User.is_regular_user.fget.__name__, 93 | # User.is_admin.fget.__name__, 94 | # ) 95 | #) 96 | 97 | @classmethod 98 | def test(cls, obj, field, value, state): 99 | """ 100 | Additional check for 'current_password' as User hasn't field 'current_password' 101 | """ 102 | if field == 'current_password': 103 | if current_user.password != value and obj.password != value: 104 | abort(code=HTTPStatus.FORBIDDEN, message="Wrong password") 105 | else: 106 | state['current_password'] = value 107 | return True 108 | return PatchJSONParameters.test(obj, field, value, state) 109 | 110 | @classmethod 111 | def replace(cls, obj, field, value, state): 112 | """ 113 | Some fields require extra permissions to be changed. 114 | 115 | Changing `is_active` and `is_regular_user` properties, current user 116 | must be a supervisor of the changing user, and `current_password` of 117 | the current user should be provided. 118 | 119 | Changing `is_admin` property requires current user to be Admin, and 120 | `current_password` of the current user should be provided.. 121 | """ 122 | if 'current_password' not in state: 123 | raise ValidationError( 124 | "Updating sensitive user settings requires `current_password` test operation " 125 | "performed before replacements." 126 | ) 127 | 128 | if field in {User.is_active.fget.__name__, User.is_regular_user.fget.__name__}: 129 | with permissions.SupervisorRolePermission( 130 | obj=obj, 131 | password_required=True, 132 | password=state['current_password'] 133 | ): 134 | # Access granted 135 | pass 136 | elif field == User.is_admin.fget.__name__: 137 | with permissions.AdminRolePermission( 138 | password_required=True, 139 | password=state['current_password'] 140 | ): 141 | # Access granted 142 | pass 143 | return super(PatchUserDetailsParameters, cls).replace(obj, field, value, state) 144 | -------------------------------------------------------------------------------- /app/modules/users/permissions/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-few-public-methods,invalid-name,abstract-method,method-hidden 3 | """ 4 | RESTful API permissions 5 | ----------------------- 6 | """ 7 | import logging 8 | #from flask_sqlalchemy import BaseQuery 9 | from flask_mongoengine import BaseQuerySet 10 | from permission import Permission as BasePermission 11 | 12 | from . import rules 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | class PermissionExtendedQuery(BaseQuerySet): 18 | """ 19 | Extends BaseQuery class from flask_sqlalchemy to add get_or_403 method 20 | 21 | Example: 22 | >>> DataTransformation.query.get_or_403(id) 23 | """ 24 | def __init__(self, permisssion, *args, **kwargs): 25 | super(PermissionExtendedQuery, self).__init__(*args, **kwargs) 26 | self.permisssion = permisssion 27 | 28 | def get_or_403(self, ident): 29 | obj = self.get_or_404(ident) 30 | with self.permisssion(obj=obj): 31 | return obj 32 | 33 | 34 | class Permission(BasePermission): 35 | """ 36 | Declares classmethod to provide extended BaseQuery to model, 37 | which adds additional method get_or_403 38 | """ 39 | 40 | @classmethod 41 | def get_query_class(cls): 42 | """ 43 | Returns extended BaseQuery class for flask_sqlalchemy model to provide get_or_403 method 44 | 45 | Example: 46 | >>> DataTransformation(db.Model): 47 | ... query_class = OwnerRolePermission.get_query_class() 48 | """ 49 | return lambda *args, **kwargs: PermissionExtendedQuery(cls, *args, **kwargs) 50 | 51 | 52 | class PasswordRequiredPermissionMixin(object): 53 | """ 54 | Helper rule mixin that ensure that user password is correct if 55 | `password_required` is set to True. 56 | """ 57 | 58 | def __init__(self, password_required=False, password=None, **kwargs): 59 | # NOTE: kwargs is required since it is a mixin 60 | """ 61 | Args: 62 | password_required (bool) - in some cases you may need to ask 63 | users for a password to allow certain actions, enforce this 64 | requirement by setting this :bool:`True`. 65 | password (str) - pass a user-specified password here. 66 | """ 67 | self._password_required = password_required 68 | self._password = password 69 | super(PasswordRequiredPermissionMixin, self).__init__(**kwargs) 70 | 71 | def rule(self): 72 | _rule = super(PasswordRequiredPermissionMixin, self).rule() 73 | if self._password_required: 74 | _rule &= rules.PasswordRequiredRule(self._password) 75 | return _rule 76 | 77 | 78 | class WriteAccessPermission(Permission): 79 | """ 80 | Require a regular user role to perform an action. 81 | """ 82 | 83 | def rule(self): 84 | return rules.InternalRoleRule() | rules.AdminRoleRule() | rules.WriteAccessRule() 85 | 86 | 87 | class RolePermission(Permission): 88 | """ 89 | This class aims to help distinguish all role-type permissions. 90 | """ 91 | 92 | def __init__(self, partial=False, **kwargs): 93 | """ 94 | Args: 95 | partial (bool) - True values is mostly useful for Swagger 96 | documentation purposes. 97 | """ 98 | self._partial = partial 99 | super(RolePermission, self).__init__(**kwargs) 100 | 101 | def rule(self): 102 | if self._partial: 103 | return rules.PartialPermissionDeniedRule() 104 | return rules.AllowAllRule() 105 | 106 | 107 | class ActiveUserRolePermission(RolePermission): 108 | """ 109 | At least Active user is required. 110 | """ 111 | 112 | def rule(self): 113 | return rules.ActiveUserRoleRule() 114 | 115 | 116 | class AdminRolePermission(PasswordRequiredPermissionMixin, RolePermission): 117 | """ 118 | Admin role is required. 119 | """ 120 | 121 | def rule(self): 122 | return ( 123 | rules.InternalRoleRule() 124 | | (rules.AdminRoleRule() & super(AdminRolePermission, self).rule()) 125 | ) 126 | 127 | 128 | class InternalRolePermission(RolePermission): 129 | """ 130 | Internal role is required. 131 | """ 132 | 133 | def rule(self): 134 | return rules.InternalRoleRule() 135 | 136 | 137 | class SupervisorRolePermission(PasswordRequiredPermissionMixin, RolePermission): 138 | """ 139 | Supervisor/Admin may execute this action. 140 | """ 141 | 142 | def __init__(self, obj=None, **kwargs): 143 | """ 144 | Args: 145 | obj (object) - any object can be passed here, which will be asked 146 | via ``check_supervisor(current_user)`` method whether a current 147 | user has enough permissions to perform an action on the given 148 | object. 149 | """ 150 | self._obj = obj 151 | super(SupervisorRolePermission, self).__init__(**kwargs) 152 | 153 | def rule(self): 154 | return ( 155 | rules.InternalRoleRule() 156 | | ( 157 | ( 158 | rules.AdminRoleRule() 159 | | rules.SupervisorRoleRule(obj=self._obj) 160 | ) 161 | & super(SupervisorRolePermission, self).rule() 162 | ) 163 | ) 164 | 165 | 166 | class OwnerRolePermission(PasswordRequiredPermissionMixin, RolePermission): 167 | """ 168 | Owner/Supervisor/Admin may execute this action. 169 | """ 170 | 171 | def __init__(self, obj=None, **kwargs): 172 | """ 173 | Args: 174 | obj (object) - any object can be passed here, which will be asked 175 | via ``check_owner(current_user)`` method whether a current user 176 | has enough permissions to perform an action on the given 177 | object. 178 | """ 179 | self._obj = obj 180 | super(OwnerRolePermission, self).__init__(**kwargs) 181 | 182 | def rule(self): 183 | return ( 184 | rules.InternalRoleRule() 185 | | ( 186 | ( 187 | rules.AdminRoleRule() 188 | | rules.OwnerRoleRule(obj=self._obj) 189 | | rules.SupervisorRoleRule(obj=self._obj) 190 | ) 191 | & super(OwnerRolePermission, self).rule() 192 | ) 193 | ) 194 | -------------------------------------------------------------------------------- /app/modules/users/permissions/rules.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-few-public-methods,invalid-name,abstract-method,method-hidden 3 | """ 4 | RESTful API Rules 5 | ----------------------- 6 | """ 7 | from flask_login import current_user 8 | from flask_restplus_patched._http import HTTPStatus 9 | from permission import Rule as BaseRule 10 | 11 | from app.extensions.api import abort 12 | 13 | 14 | class DenyAbortMixin(object): 15 | """ 16 | A helper permissions mixin raising an HTTP Error (specified in 17 | ``DENY_ABORT_CODE``) on deny. 18 | 19 | NOTE: Apply this mixin before Rule class so it can override NotImplemented 20 | deny method. 21 | """ 22 | 23 | DENY_ABORT_HTTP_CODE = HTTPStatus.FORBIDDEN 24 | DENY_ABORT_MESSAGE = None 25 | 26 | def deny(self): 27 | """ 28 | Abort HTTP request by raising HTTP error exception with a specified 29 | HTTP code. 30 | """ 31 | return abort(code=self.DENY_ABORT_HTTP_CODE, message=self.DENY_ABORT_MESSAGE) 32 | 33 | 34 | class Rule(BaseRule): 35 | """ 36 | Experimental base Rule class that helps to automatically handle inherited 37 | rules. 38 | """ 39 | 40 | def base(self): 41 | # XXX: it handles only the first appropriate Rule base class 42 | # TODO: PR this case to permission project 43 | for base_class in self.__class__.__bases__: 44 | if issubclass(base_class, Rule): 45 | if base_class in {Rule, BaseRule}: 46 | continue 47 | return base_class() 48 | 49 | 50 | class AllowAllRule(Rule): 51 | """ 52 | Helper rule that always grants access. 53 | """ 54 | 55 | def check(self): 56 | return True 57 | 58 | 59 | class WriteAccessRule(DenyAbortMixin, Rule): 60 | """ 61 | Ensure that the current_user has has write access. 62 | """ 63 | 64 | def check(self): 65 | return current_user.is_regular_user 66 | 67 | 68 | class ActiveUserRoleRule(DenyAbortMixin, Rule): 69 | """ 70 | Ensure that the current_user is activated. 71 | """ 72 | 73 | def check(self): 74 | # Do not override DENY_ABORT_HTTP_CODE because inherited classes will 75 | # better use HTTP 403/Forbidden code on denial. 76 | self.DENY_ABORT_HTTP_CODE = HTTPStatus.UNAUTHORIZED 77 | # NOTE: `is_active` implies `is_authenticated`. 78 | return current_user.is_active 79 | 80 | 81 | class PasswordRequiredRule(DenyAbortMixin, Rule): 82 | """ 83 | Ensure that the current user has provided a correct password. 84 | """ 85 | 86 | def __init__(self, password, **kwargs): 87 | super(PasswordRequiredRule, self).__init__(**kwargs) 88 | self._password = password 89 | 90 | def check(self): 91 | return current_user.password == self._password 92 | 93 | 94 | class AdminRoleRule(ActiveUserRoleRule): 95 | """ 96 | Ensure that the current_user has an Admin role. 97 | """ 98 | 99 | def check(self): 100 | return current_user.is_admin 101 | 102 | 103 | class InternalRoleRule(ActiveUserRoleRule): 104 | """ 105 | Ensure that the current_user has an Internal role. 106 | """ 107 | 108 | def check(self): 109 | return current_user.is_internal 110 | 111 | 112 | class PartialPermissionDeniedRule(Rule): 113 | """ 114 | Helper rule that must fail on every check since it should never be checked. 115 | """ 116 | 117 | def check(self): 118 | raise RuntimeError("Partial permissions are not intended to be checked") 119 | 120 | 121 | class SupervisorRoleRule(ActiveUserRoleRule): 122 | """ 123 | Ensure that the current_user has a Supervisor access to the given object. 124 | """ 125 | 126 | def __init__(self, obj, **kwargs): 127 | super(SupervisorRoleRule, self).__init__(**kwargs) 128 | self._obj = obj 129 | 130 | def check(self): 131 | if not hasattr(self._obj, 'check_supervisor'): 132 | return False 133 | return self._obj.check_supervisor(current_user) is True 134 | 135 | 136 | class OwnerRoleRule(ActiveUserRoleRule): 137 | """ 138 | Ensure that the current_user has an Owner access to the given object. 139 | """ 140 | 141 | def __init__(self, obj, **kwargs): 142 | super(OwnerRoleRule, self).__init__(**kwargs) 143 | self._obj = obj 144 | 145 | def check(self): 146 | if not hasattr(self._obj, 'check_owner'): 147 | return False 148 | return self._obj.check_owner(current_user) is True 149 | -------------------------------------------------------------------------------- /app/modules/users/resources.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-few-public-methods,invalid-name,bad-continuation 3 | """ 4 | RESTful API User resources 5 | -------------------------- 6 | """ 7 | 8 | import logging 9 | 10 | from flask_login import current_user 11 | from flask_restplus_patched import Resource 12 | from flask_restplus_patched._http import HTTPStatus 13 | 14 | from app.extensions.api import Namespace 15 | from app.extensions.api.parameters import PaginationParameters 16 | 17 | from . import permissions, schemas, parameters 18 | from .models import db, User 19 | 20 | 21 | log = logging.getLogger(__name__) 22 | api = Namespace('users', description="Users") 23 | 24 | 25 | @api.route('/') 26 | class Users(Resource): 27 | """ 28 | Manipulations with users. 29 | """ 30 | 31 | @api.login_required(oauth_scopes=['users:read']) 32 | @api.permission_required(permissions.AdminRolePermission()) 33 | @api.parameters(PaginationParameters()) 34 | @api.response(schemas.BaseUserSchema(many=True)) 35 | def get(self, args): 36 | """ 37 | List of users. 38 | 39 | Returns a list of users starting from ``offset`` limited by ``limit`` 40 | parameter. 41 | """ 42 | #return User.query.offset(args['offset']).limit(args['limit']) 43 | return User.objects.skip(args['offset']).limit(args['limit']) 44 | 45 | @api.parameters(parameters.AddUserParameters()) 46 | @api.response(schemas.DetailedUserSchema()) 47 | @api.response(code=HTTPStatus.FORBIDDEN) 48 | @api.response(code=HTTPStatus.CONFLICT) 49 | @api.doc(id='create_user') 50 | def post(self, args): 51 | """ 52 | Create a new user. 53 | """ 54 | with api.commit_or_abort( 55 | default_error_message="Failed to create a new user." 56 | ): 57 | new_user = User(**args) 58 | new_user.save() 59 | #db.session.add(new_user) 60 | return new_user 61 | 62 | 63 | @api.route('/signup_form') 64 | class UserSignupForm(Resource): 65 | 66 | @api.response(schemas.UserSignupFormSchema()) 67 | def get(self): 68 | """ 69 | Get signup form keys. 70 | 71 | This endpoint must be used in order to get a server reCAPTCHA public key which 72 | must be used to receive a reCAPTCHA secret key for POST /users/ form. 73 | """ 74 | # TODO: 75 | return {"recaptcha_server_key": "TODO"} 76 | 77 | 78 | @api.route('/') 79 | @api.login_required(oauth_scopes=['users:read']) 80 | @api.response( 81 | code=HTTPStatus.NOT_FOUND, 82 | description="User not found.", 83 | ) 84 | @api.resolve_object_by_model(User, 'user') 85 | class UserByID(Resource): 86 | """ 87 | Manipulations with a specific user. 88 | """ 89 | 90 | @api.permission_required( 91 | permissions.OwnerRolePermission, 92 | kwargs_on_request=lambda kwargs: {'obj': kwargs['user']} 93 | ) 94 | @api.response(schemas.DetailedUserSchema()) 95 | def get(self, user): 96 | """ 97 | Get user details by ID. 98 | """ 99 | return user 100 | 101 | @api.login_required(oauth_scopes=['users:write']) 102 | @api.permission_required( 103 | permissions.OwnerRolePermission, 104 | kwargs_on_request=lambda kwargs: {'obj': kwargs['user']} 105 | ) 106 | @api.permission_required(permissions.WriteAccessPermission()) 107 | @api.parameters(parameters.PatchUserDetailsParameters()) 108 | @api.response(schemas.DetailedUserSchema()) 109 | @api.response(code=HTTPStatus.CONFLICT) 110 | def patch(self, args, user): 111 | """ 112 | Patch user details by ID. 113 | """ 114 | with api.commit_or_abort( 115 | default_error_message="Failed to update user details." 116 | ): 117 | parameters.PatchUserDetailsParameters.perform_patch(args, user) 118 | #db.session.merge(user) 119 | user.save() 120 | return user 121 | 122 | 123 | @api.route('/me') 124 | @api.login_required(oauth_scopes=['users:read']) 125 | class UserMe(Resource): 126 | """ 127 | Useful reference to the authenticated user itself. 128 | """ 129 | 130 | @api.response(schemas.DetailedUserSchema()) 131 | def get(self): 132 | """ 133 | Get current user details. 134 | """ 135 | #return User.query.get_or_404(current_user.id) 136 | return User.objects.get_or_404(id=current_user.id) 137 | -------------------------------------------------------------------------------- /app/modules/users/schemas.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-few-public-methods 3 | """ 4 | User schemas 5 | ------------ 6 | """ 7 | 8 | from flask_marshmallow import base_fields 9 | from flask_restplus_patched import Schema, ModelSchema 10 | 11 | from .models import User 12 | 13 | 14 | class BaseUserSchema(ModelSchema): 15 | """ 16 | Base user schema exposes only the most general fields. 17 | """ 18 | 19 | class Meta: 20 | # pylint: disable=missing-docstring 21 | model = User 22 | fields = ( 23 | 'id', 24 | 'username', 25 | 'first_name', 26 | 'middle_name', 27 | 'last_name', 28 | ) 29 | dump_only = ( 30 | 'id', 31 | ) 32 | #fields = ( 33 | # User.id.key, 34 | # User.username.key, 35 | # User.first_name.key, 36 | # User.middle_name.key, 37 | # User.last_name.key, 38 | #) 39 | #dump_only = ( 40 | # User.id.key, 41 | #) 42 | 43 | 44 | 45 | class DetailedUserSchema(BaseUserSchema): 46 | """ 47 | Detailed user schema exposes all useful fields. 48 | """ 49 | 50 | class Meta(BaseUserSchema.Meta): 51 | fields = BaseUserSchema.Meta.fields + ( 52 | 'email', 53 | 'created', 54 | 'updated', 55 | User.is_active.fget.__name__, 56 | User.is_regular_user.fget.__name__, 57 | User.is_admin.fget.__name__, 58 | ) 59 | #fields = BaseUserSchema.Meta.fields + ( 60 | # User.email.key, 61 | # User.created.key, 62 | # User.updated.key, 63 | # User.is_active.fget.__name__, 64 | # User.is_regular_user.fget.__name__, 65 | # User.is_admin.fget.__name__, 66 | #) 67 | 68 | 69 | class UserSignupFormSchema(Schema): 70 | 71 | recaptcha_server_key = base_fields.String(required=True) 72 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=0.11 2 | 3 | flask-restplus>=0.9.1,!=0.10.0 4 | 5 | Flask-Cors==3.0.2 6 | 7 | SQLAlchemy==1.1.5 8 | SQLAlchemy-Utils==0.32.12 9 | Flask-SQLAlchemy==2.2 10 | Alembic==0.8.10 11 | werkzeug==0.11.15 12 | 13 | marshmallow>=2.13.5 14 | flask-marshmallow==0.7.0 15 | marshmallow-sqlalchemy==0.12.0 16 | webargs>=1.4.0 17 | apispec>=0.20.0 18 | 19 | bcrypt==3.1.3 20 | passlib==1.7.1 21 | #Flask-OAuthlib>0.9.3 22 | https://github.com/lepture/flask-oauthlib/archive/8a14799a8e7270eab776991c8eae87875f6fab6d.zip 23 | Flask-Login==0.4.0 24 | permission==0.4.1 25 | 26 | arrow==0.8.0 27 | 28 | six 29 | enum34; python_version < '3.4' 30 | 31 | marshmallow-mongoengine==0.7.8 32 | mongoengine==0.13.0 33 | pymongo==3.4.0 34 | flask-mongoengine==0.9.3 35 | -------------------------------------------------------------------------------- /app/templates/authorize.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Authorization 6 | 7 | 8 |

Client: {{ client.client_id }}

9 |

User: {{ user.username }}

10 |
11 |

Allow access?

12 | 13 | 14 | 15 | 16 | {% if state %} 17 | 18 | {% endif %} 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /app/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if user %} 9 |

You are {{ user.username }}

10 | {% else %} 11 |

You are not authenticated

12 | {% endif %} 13 | 14 |

Type any username:

15 |
16 | 17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /app/templates/swagger-ui-libs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if config.DEBUG %} 9 | 10 | {% else %} 11 | 12 | {% endif %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/templates/swagger-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | {% include 'swagger-ui-css.html' %} 6 | {% include 'swagger-ui-libs.html' %} 7 | 67 | 68 | 69 | 70 | 78 |
 
79 |
80 | 81 | 82 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=too-few-public-methods,invalid-name,missing-docstring 2 | import os 3 | 4 | 5 | class BaseConfig(object): 6 | SECRET_KEY = 'this-really-needs-to-be-changed' 7 | 8 | PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) 9 | 10 | # POSTGRESQL 11 | # DB_USER = 'user' 12 | # DB_PASSWORD = 'password' 13 | # DB_NAME = 'restplusdb' 14 | # DB_HOST = 'localhost' 15 | # DB_PORT = 5432 16 | # SQLALCHEMY_DATABASE_URI = 'postgresql://{user}:{password}@{host}:{port}/{name}'.format( 17 | # user=DB_USER, 18 | # password=DB_PASSWORD, 19 | # host=DB_HOST, 20 | # port=DB_PORT, 21 | # name=DB_NAME, 22 | # ) 23 | 24 | # SQLITE 25 | SQLALCHEMY_DATABASE_URI = 'sqlite:///%s' % (os.path.join(PROJECT_ROOT, "example.db")) 26 | 27 | DEBUG = False 28 | ERROR_404_HELP = False 29 | 30 | AUTHORIZATIONS = { 31 | 'oauth2_password': { 32 | 'type': 'oauth2', 33 | 'flow': 'password', 34 | 'scopes': {}, 35 | 'tokenUrl': '/auth/oauth2/token', 36 | }, 37 | # TODO: implement other grant types for third-party apps 38 | #'oauth2_implicit': { 39 | # 'type': 'oauth2', 40 | # 'flow': 'implicit', 41 | # 'scopes': {}, 42 | # 'authorizationUrl': '/auth/oauth2/authorize', 43 | #}, 44 | } 45 | 46 | ENABLED_MODULES = ( 47 | 'auth', 48 | 49 | 'users', 50 | 'teams', 51 | 52 | 'api', 53 | ) 54 | 55 | STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') 56 | 57 | SWAGGER_UI_JSONEDITOR = True 58 | SWAGGER_UI_OAUTH_CLIENT_ID = 'documentation' 59 | SWAGGER_UI_OAUTH_REALM = "Authentication for Flask-RESTplus Example server documentation" 60 | SWAGGER_UI_OAUTH_APP_NAME = "Flask-RESTplus Example server documentation" 61 | 62 | # TODO: consider if these are relevant for this project 63 | SQLALCHEMY_TRACK_MODIFICATIONS = True 64 | CSRF_ENABLED = True 65 | 66 | 67 | class ProductionConfig(BaseConfig): 68 | SECRET_KEY = os.getenv('CLOUDSML_API_SERVER_SECRET_KEY') 69 | SQLALCHEMY_DATABASE_URI = os.getenv('CLOUDSML_API_SERVER_SQLALCHEMY_DATABASE_URI') 70 | 71 | 72 | class DevelopmentConfig(BaseConfig): 73 | DEBUG = True 74 | 75 | 76 | class TestingConfig(BaseConfig): 77 | TESTING = True 78 | 79 | # Use in-memory SQLite database for testing 80 | SQLALCHEMY_DATABASE_URI = 'sqlite://' 81 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/conftest.py -------------------------------------------------------------------------------- /deploy/README.md: -------------------------------------------------------------------------------- 1 | RESTful API Server Example deployments 2 | ========================== 3 | 4 | This project showcases my vision on how the RESTful API server should be 5 | implemented. See [api directory](../../api/) for detailed information. 6 | 7 | Project Structure 8 | ----------------- 9 | 10 | ### Root folder 11 | 12 | Folders: 13 | 14 | * `stack1` - RESTful API Server Example behind an nginx reverse proxy, stack 15 | managed using Docker Compose 16 | 17 | 18 | Files: 19 | 20 | * `README.md` 21 | 22 | 23 | Installation 24 | ------------ 25 | 26 | Information provided in each stack. 27 | -------------------------------------------------------------------------------- /deploy/stack1/README.md: -------------------------------------------------------------------------------- 1 | Stack 1 - Docker Compose topology + nginx reverse proxy 2 | ========================== 3 | 4 | This stack showcases an implementation of the RESTful API server in a 5 | Docker Compose topology with an nginx reverse proxy. 6 | 7 | Project Structure 8 | ----------------- 9 | 10 | ### Root folder 11 | 12 | Folders: 13 | 14 | * `revproxy` - Basic nginx configuration 15 | 16 | Files: 17 | 18 | * `README.md` 19 | * `docker-compose.yml` - Docker Compose config file which is used to build a Docker 20 | images and run the topology (api and reverse proxy). 21 | 22 | 23 | Dependencies 24 | ------------ 25 | 26 | ### Project Dependencies 27 | 28 | * [**RESTful API Server Example**](../../api/) 29 | * [**Docker**](https://www.docker.com/) 1.11+ 30 | * [**Docker Compose**](https://docs.docker.com/compose/overview/) 1.8+ 31 | 32 | 33 | Installation 34 | ------------ 35 | 36 | ### From sources 37 | 38 | #### Clone the Project 39 | 40 | ```bash 41 | $ git clone https://github.com/frol/flask-restplus-server-example.git 42 | ``` 43 | 44 | #### Run the application topology 45 | 46 | It is very easy to start exploring the example using Docker Compose: 47 | 48 | ```bash 49 | $ cd deploy/stack1 50 | $ docker-compose build 51 | $ docker-compose up 52 | ``` 53 | 54 | To tear it down: 55 | ```bash 56 | $ docker-compose kill 57 | $ docker-compose rm -fv 58 | ``` 59 | 60 | Should you need to change the reverse proxy port, just change the following section 61 | ``` 62 | revproxy: 63 | restart: always 64 | build: ./revproxy 65 | ports: 66 | - "80:80" 67 | links: 68 | - api:api 69 | ``` 70 | 71 | to 72 | 73 | ``` 74 | revproxy: 75 | restart: always 76 | build: ./revproxy 77 | ports: 78 | - ":80" 79 | links: 80 | - api:api 81 | ``` 82 | 83 | Quickstart 84 | ---------- 85 | 86 | Open reverse proxy home page 87 | [http://127.0.0.1/](http://127.0.0.1/) or http://127.0.0.1:CUSTOM_PORT if you changed it 88 | 89 | Open online interactive API documentation: 90 | [http://127.0.0.1/api/v1/](http://127.0.0.1/api/v1/) 91 | 92 | Autogenerated swagger config is always available from 93 | [http://127.0.0.1:5000/api/v1/swagger.json](http://127.0.0.1:5000/api/v1/swagger.json) 94 | 95 | Read the [RESTful API Server Example](../../api/) doc for more information about the API itself. 96 | 97 | # Contributors 98 | 99 | * Patrice LACHANCE (patrice at itisopen.net) 100 | 101 | -------------------------------------------------------------------------------- /deploy/stack1/docker-compose.yml: -------------------------------------------------------------------------------- 1 | revproxy: 2 | restart: always 3 | build: ./revproxy 4 | ports: 5 | - "80:80" 6 | links: 7 | - api:api 8 | 9 | api: 10 | restart: always 11 | build: ../../ 12 | expose: 13 | - "5000" 14 | 15 | -------------------------------------------------------------------------------- /deploy/stack1/revproxy/Dockerfile: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # Reverse Proxy 3 | ############################################################################## 4 | FROM nginx:alpine 5 | 6 | ADD conf.d/ /etc/nginx/conf.d 7 | ADD index.html /etc/nginx/html/ 8 | -------------------------------------------------------------------------------- /deploy/stack1/revproxy/conf.d/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | server_name _; 4 | charset utf-8; 5 | 6 | # Rules could be optimized but as is it could help others to understand and customise them. 7 | location * { 8 | if ($request_method = 'OPTIONS') { 9 | # CORS configuration, from http://enable-cors.org/server_nginx.html 10 | add_header 'Access-Control-Allow-Origin' '*'; 11 | # 12 | # Om nom nom cookies 13 | # 14 | add_header 'Access-Control-Allow-Credentials' 'true'; 15 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; 16 | # 17 | # Custom headers and headers various browsers *should* be OK with but aren't 18 | # 19 | add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; 20 | # 21 | # Tell client that this pre-flight info is valid for 20 days 22 | # 23 | add_header 'Access-Control-Max-Age' 1728000; 24 | add_header 'Content-Type' 'text/plain charset=UTF-8'; 25 | add_header 'Content-Length' 0; 26 | return 204; 27 | } 28 | if ($request_method = 'POST') { 29 | add_header 'Access-Control-Allow-Origin' '*'; 30 | add_header 'Access-Control-Allow-Credentials' 'true'; 31 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; 32 | add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; 33 | } 34 | if ($request_method = 'GET') { 35 | add_header 'Access-Control-Allow-Origin' '*'; 36 | add_header 'Access-Control-Allow-Credentials' 'true'; 37 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; 38 | add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type'; 39 | } 40 | } 41 | 42 | location /api/ { 43 | proxy_pass http://api:5000/api/; 44 | proxy_redirect off; 45 | proxy_set_header Host $host; 46 | proxy_set_header X-Real-IP $remote_addr; 47 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 48 | proxy_set_header X-Forwarded-Proto https; 49 | } 50 | location /auth/ { 51 | proxy_pass http://api:5000/auth/; 52 | proxy_redirect off; 53 | proxy_set_header Host $host; 54 | proxy_set_header X-Real-IP $remote_addr; 55 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 56 | proxy_set_header X-Forwarded-Proto https; 57 | } 58 | location /swaggerui/ { 59 | proxy_pass http://api:5000/swaggerui/; 60 | proxy_redirect off; 61 | proxy_set_header Host $host; 62 | proxy_set_header X-Real-IP $remote_addr; 63 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 64 | proxy_set_header X-Forwarded-Proto https; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /deploy/stack1/revproxy/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Reverse Proxy 4 | 5 | 6 | You just hit the nginx reverse proxy! 7 | You might want to look at the API... 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/static/Flask_RESTplus_Example_API.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/docs/static/Flask_RESTplus_Example_API.png -------------------------------------------------------------------------------- /flask_restplus_patched/__init__.py: -------------------------------------------------------------------------------- 1 | from flask_restplus import * 2 | from .api import Api 3 | from .model import Schema, ModelSchema, DefaultHTTPErrorSchema 4 | from .namespace import Namespace 5 | from .parameters import Parameters, PostFormParameters, PatchJSONParameters 6 | from .swagger import Swagger 7 | from .resource import Resource 8 | -------------------------------------------------------------------------------- /flask_restplus_patched/_http.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | This file is backported from Python 3.5 http built-in module. 4 | """ 5 | 6 | from enum import IntEnum 7 | 8 | 9 | class HTTPStatus(IntEnum): 10 | """HTTP status codes and reason phrases 11 | 12 | Status codes from the following RFCs are all observed: 13 | 14 | * RFC 7231: Hypertext Transfer Protocol (HTTP/1.1), obsoletes 2616 15 | * RFC 6585: Additional HTTP Status Codes 16 | * RFC 3229: Delta encoding in HTTP 17 | * RFC 4918: HTTP Extensions for WebDAV, obsoletes 2518 18 | * RFC 5842: Binding Extensions to WebDAV 19 | * RFC 7238: Permanent Redirect 20 | * RFC 2295: Transparent Content Negotiation in HTTP 21 | * RFC 2774: An HTTP Extension Framework 22 | """ 23 | def __new__(cls, value, phrase, description=''): 24 | obj = int.__new__(cls, value) 25 | obj._value_ = value 26 | 27 | obj.phrase = phrase 28 | obj.description = description 29 | return obj 30 | 31 | def __str__(self): 32 | return str(self.value) 33 | 34 | # informational 35 | CONTINUE = 100, 'Continue', 'Request received, please continue' 36 | SWITCHING_PROTOCOLS = (101, 'Switching Protocols', 37 | 'Switching to new protocol; obey Upgrade header') 38 | PROCESSING = 102, 'Processing' 39 | 40 | # success 41 | OK = 200, 'OK', 'Request fulfilled, document follows' 42 | CREATED = 201, 'Created', 'Document created, URL follows' 43 | ACCEPTED = (202, 'Accepted', 44 | 'Request accepted, processing continues off-line') 45 | NON_AUTHORITATIVE_INFORMATION = (203, 46 | 'Non-Authoritative Information', 'Request fulfilled from cache') 47 | NO_CONTENT = 204, 'No Content', 'Request fulfilled, nothing follows' 48 | RESET_CONTENT = 205, 'Reset Content', 'Clear input form for further input' 49 | PARTIAL_CONTENT = 206, 'Partial Content', 'Partial content follows' 50 | MULTI_STATUS = 207, 'Multi-Status' 51 | ALREADY_REPORTED = 208, 'Already Reported' 52 | IM_USED = 226, 'IM Used' 53 | 54 | # redirection 55 | MULTIPLE_CHOICES = (300, 'Multiple Choices', 56 | 'Object has several resources -- see URI list') 57 | MOVED_PERMANENTLY = (301, 'Moved Permanently', 58 | 'Object moved permanently -- see URI list') 59 | FOUND = 302, 'Found', 'Object moved temporarily -- see URI list' 60 | SEE_OTHER = 303, 'See Other', 'Object moved -- see Method and URL list' 61 | NOT_MODIFIED = (304, 'Not Modified', 62 | 'Document has not changed since given time') 63 | USE_PROXY = (305, 'Use Proxy', 64 | 'You must use proxy specified in Location to access this resource') 65 | TEMPORARY_REDIRECT = (307, 'Temporary Redirect', 66 | 'Object moved temporarily -- see URI list') 67 | PERMANENT_REDIRECT = (308, 'Permanent Redirect', 68 | 'Object moved temporarily -- see URI list') 69 | 70 | # client error 71 | BAD_REQUEST = (400, 'Bad Request', 72 | 'Bad request syntax or unsupported method') 73 | UNAUTHORIZED = (401, 'Unauthorized', 74 | 'No permission -- see authorization schemes') 75 | PAYMENT_REQUIRED = (402, 'Payment Required', 76 | 'No payment -- see charging schemes') 77 | FORBIDDEN = (403, 'Forbidden', 78 | 'Request forbidden -- authorization will not help') 79 | NOT_FOUND = (404, 'Not Found', 80 | 'Nothing matches the given URI') 81 | METHOD_NOT_ALLOWED = (405, 'Method Not Allowed', 82 | 'Specified method is invalid for this resource') 83 | NOT_ACCEPTABLE = (406, 'Not Acceptable', 84 | 'URI not available in preferred format') 85 | PROXY_AUTHENTICATION_REQUIRED = (407, 86 | 'Proxy Authentication Required', 87 | 'You must authenticate with this proxy before proceeding') 88 | REQUEST_TIMEOUT = (408, 'Request Timeout', 89 | 'Request timed out; try again later') 90 | CONFLICT = 409, 'Conflict', 'Request conflict' 91 | GONE = (410, 'Gone', 92 | 'URI no longer exists and has been permanently removed') 93 | LENGTH_REQUIRED = (411, 'Length Required', 94 | 'Client must specify Content-Length') 95 | PRECONDITION_FAILED = (412, 'Precondition Failed', 96 | 'Precondition in headers is false') 97 | REQUEST_ENTITY_TOO_LARGE = (413, 'Request Entity Too Large', 98 | 'Entity is too large') 99 | REQUEST_URI_TOO_LONG = (414, 'Request-URI Too Long', 100 | 'URI is too long') 101 | UNSUPPORTED_MEDIA_TYPE = (415, 'Unsupported Media Type', 102 | 'Entity body in unsupported format') 103 | REQUESTED_RANGE_NOT_SATISFIABLE = (416, 104 | 'Requested Range Not Satisfiable', 105 | 'Cannot satisfy request range') 106 | EXPECTATION_FAILED = (417, 'Expectation Failed', 107 | 'Expect condition could not be satisfied') 108 | UNPROCESSABLE_ENTITY = 422, 'Unprocessable Entity' 109 | LOCKED = 423, 'Locked' 110 | FAILED_DEPENDENCY = 424, 'Failed Dependency' 111 | UPGRADE_REQUIRED = 426, 'Upgrade Required' 112 | PRECONDITION_REQUIRED = (428, 'Precondition Required', 113 | 'The origin server requires the request to be conditional') 114 | TOO_MANY_REQUESTS = (429, 'Too Many Requests', 115 | 'The user has sent too many requests in ' 116 | 'a given amount of time ("rate limiting")') 117 | REQUEST_HEADER_FIELDS_TOO_LARGE = (431, 118 | 'Request Header Fields Too Large', 119 | 'The server is unwilling to process the request because its header ' 120 | 'fields are too large') 121 | 122 | # server errors 123 | INTERNAL_SERVER_ERROR = (500, 'Internal Server Error', 124 | 'Server got itself in trouble') 125 | NOT_IMPLEMENTED = (501, 'Not Implemented', 126 | 'Server does not support this operation') 127 | BAD_GATEWAY = (502, 'Bad Gateway', 128 | 'Invalid responses from another server/proxy') 129 | SERVICE_UNAVAILABLE = (503, 'Service Unavailable', 130 | 'The server cannot process the request due to a high load') 131 | GATEWAY_TIMEOUT = (504, 'Gateway Timeout', 132 | 'The gateway server did not receive a timely response') 133 | HTTP_VERSION_NOT_SUPPORTED = (505, 'HTTP Version Not Supported', 134 | 'Cannot fulfill request') 135 | VARIANT_ALSO_NEGOTIATES = 506, 'Variant Also Negotiates' 136 | INSUFFICIENT_STORAGE = 507, 'Insufficient Storage' 137 | LOOP_DETECTED = 508, 'Loop Detected' 138 | NOT_EXTENDED = 510, 'Not Extended' 139 | NETWORK_AUTHENTICATION_REQUIRED = (511, 140 | 'Network Authentication Required', 141 | 'The client needs to authenticate to gain network access') 142 | -------------------------------------------------------------------------------- /flask_restplus_patched/api.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from flask_restplus import Api as OriginalApi 3 | from werkzeug import cached_property 4 | 5 | from ._http import HTTPStatus 6 | from .namespace import Namespace 7 | from .swagger import Swagger 8 | 9 | 10 | class Api(OriginalApi): 11 | 12 | @cached_property 13 | def __schema__(self): 14 | # The only purpose of this method is to pass custom Swagger class 15 | return Swagger(self).as_dict() 16 | 17 | def init_app(self, app): 18 | super(Api, self).init_app(app) 19 | app.errorhandler(HTTPStatus.UNPROCESSABLE_ENTITY.value)(handle_validation_error) 20 | 21 | def namespace(self, *args, **kwargs): 22 | # The only purpose of this method is to pass a custom Namespace class 23 | _namespace = Namespace(*args, **kwargs) 24 | self.add_namespace(_namespace) 25 | return _namespace 26 | 27 | 28 | # Return validation errors as JSON 29 | def handle_validation_error(err): 30 | exc = err.data['exc'] 31 | return jsonify({ 32 | 'status': HTTPStatus.UNPROCESSABLE_ENTITY.value, 33 | 'message': exc.messages 34 | }), HTTPStatus.UNPROCESSABLE_ENTITY.value 35 | -------------------------------------------------------------------------------- /flask_restplus_patched/model.py: -------------------------------------------------------------------------------- 1 | from apispec.ext.marshmallow.swagger import fields2jsonschema, field2property 2 | import flask_marshmallow 3 | import marshmallow_mongoengine 4 | from werkzeug import cached_property 5 | 6 | from flask_restplus.model import Model as OriginalModel 7 | 8 | 9 | class SchemaMixin(object): 10 | 11 | def __deepcopy__(self, memo): 12 | # XXX: Flask-RESTplus makes unnecessary data copying, while 13 | # marshmallow.Schema doesn't support deepcopyng. 14 | return self 15 | 16 | 17 | class Schema(SchemaMixin, flask_marshmallow.Schema): 18 | pass 19 | 20 | 21 | #if flask_marshmallow.has_sqla: 22 | # class ModelSchema(SchemaMixin, flask_marshmallow.sqla.ModelSchema): 23 | # pass 24 | #else: 25 | class ModelSchema(SchemaMixin, marshmallow_mongoengine.ModelSchema): 26 | pass 27 | 28 | 29 | 30 | class DefaultHTTPErrorSchema(Schema): 31 | status = flask_marshmallow.base_fields.Integer() 32 | message = flask_marshmallow.base_fields.String() 33 | 34 | def __init__(self, http_code, **kwargs): 35 | super(DefaultHTTPErrorSchema, self).__init__(**kwargs) 36 | self.fields['status'].default = http_code 37 | 38 | 39 | class Model(OriginalModel): 40 | 41 | def __init__(self, name, model, **kwargs): 42 | # XXX: Wrapping with __schema__ is not a very elegant solution. 43 | super(Model, self).__init__(name, {'__schema__': model}, **kwargs) 44 | 45 | @cached_property 46 | def __schema__(self): 47 | schema = self['__schema__'] 48 | if isinstance(schema, flask_marshmallow.Schema): 49 | return fields2jsonschema(schema.fields) 50 | elif isinstance(schema, flask_marshmallow.base_fields.FieldABC): 51 | return field2property(schema) 52 | raise NotImplementedError() 53 | -------------------------------------------------------------------------------- /flask_restplus_patched/resource.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=protected-access 3 | import flask 4 | from flask_restplus import Resource as OriginalResource 5 | from werkzeug.exceptions import HTTPException 6 | 7 | from ._http import HTTPStatus 8 | 9 | 10 | class Resource(OriginalResource): 11 | """ 12 | Extended Flast-RESTPlus Resource to add options method 13 | """ 14 | 15 | def __init__(self, *args, **kwargs): 16 | super(Resource, self).__init__(*args, **kwargs) 17 | 18 | @classmethod 19 | def _apply_decorator_to_methods(cls, decorator): 20 | """ 21 | This helper can apply a given decorator to all methods on the current 22 | Resource. 23 | 24 | NOTE: In contrast to ``Resource.method_decorators``, which has a 25 | similar use-case, this method applies decorators directly and override 26 | methods in-place, while the decorators listed in 27 | ``Resource.method_decorators`` are applied on every request which is 28 | quite a waste of resources. 29 | """ 30 | for method in cls.methods: 31 | method_name = method.lower() 32 | decorated_method_func = decorator(getattr(cls, method_name)) 33 | setattr(cls, method_name, decorated_method_func) 34 | 35 | def options(self, *args, **kwargs): 36 | """ 37 | Check which methods are allowed. 38 | 39 | Use this method if you need to know what operations are allowed to be 40 | performed on this endpoint, e.g. to decide wether to display a button 41 | in your UI. 42 | 43 | The list of allowed methods is provided in `Allow` response header. 44 | """ 45 | # This is a generic implementation of OPTIONS method for resources. 46 | # This method checks every permissions provided as decorators for other 47 | # methods to provide information about what methods `current_user` can 48 | # use. 49 | method_funcs = [getattr(self, m.lower()) for m in self.methods] 50 | allowed_methods = [] 51 | request_oauth_backup = getattr(flask.request, 'oauth', None) 52 | for method_func in method_funcs: 53 | if getattr(method_func, '_access_restriction_decorators', None): 54 | if not hasattr(method_func, '_cached_fake_method_func'): 55 | fake_method_func = lambda *args, **kwargs: True 56 | # `__name__` is used in `login_required` decorator, so it 57 | # is required to fake this also 58 | fake_method_func.__name__ = 'options' 59 | 60 | # Decorate the fake method with the registered access 61 | # restriction decorators 62 | for decorator in method_func._access_restriction_decorators: 63 | fake_method_func = decorator(fake_method_func) 64 | 65 | # Cache the `fake_method_func` to avoid redoing this over 66 | # and over again 67 | method_func.__dict__['_cached_fake_method_func'] = fake_method_func 68 | else: 69 | fake_method_func = method_func._cached_fake_method_func 70 | 71 | flask.request.oauth = None 72 | try: 73 | fake_method_func(self, *args, **kwargs) 74 | except HTTPException: 75 | # This method is not allowed, so skip it 76 | continue 77 | 78 | allowed_methods.append(method_func.__name__.upper()) 79 | flask.request.oauth = request_oauth_backup 80 | 81 | return flask.Response( 82 | status=HTTPStatus.NO_CONTENT, 83 | headers={'Allow': ", ".join(allowed_methods)} 84 | ) 85 | -------------------------------------------------------------------------------- /flask_restplus_patched/swagger.py: -------------------------------------------------------------------------------- 1 | from apispec.ext.marshmallow.swagger import schema2parameters 2 | from flask_restplus.swagger import Swagger as OriginalSwagger 3 | 4 | 5 | class Swagger(OriginalSwagger): 6 | 7 | def parameters_for(self, doc): 8 | schema = doc['params'] 9 | 10 | if not schema: 11 | return [] 12 | if isinstance(schema, list): 13 | return schema 14 | if isinstance(schema, dict) and all(isinstance(field, dict) for field in schema.values()): 15 | return list(schema.values()) 16 | 17 | if 'in' in schema.context and 'json' in schema.context['in']: 18 | default_location = 'body' 19 | else: 20 | default_location = 'query' 21 | return schema2parameters(schema, default_in=default_location, required=True) 22 | -------------------------------------------------------------------------------- /local_config.py.template: -------------------------------------------------------------------------------- 1 | from config import DevelopmentConfig 2 | 3 | 4 | class LocalConfig(DevelopmentConfig): 5 | pass 6 | -------------------------------------------------------------------------------- /migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/migrations/__init__.py -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Setup logging 12 | sqlalchemy_logger = logging.getLogger('sqlalchemy') 13 | if sqlalchemy_logger.level == logging.NOTSET: 14 | sqlalchemy_logger.setLevel(logging.WARN) 15 | 16 | alembic_logger = logging.getLogger('alembic') 17 | if alembic_logger.level == logging.NOTSET: 18 | alembic_logger.setLevel(logging.INFO) 19 | 20 | logger = logging.getLogger('alembic.env') 21 | 22 | # add your model's MetaData object here 23 | # for 'autogenerate' support 24 | # from myapp import mymodel 25 | # target_metadata = mymodel.Base.metadata 26 | from flask import current_app 27 | config.set_main_option('sqlalchemy.url', 28 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 29 | target_metadata = current_app.extensions['migrate'].db.metadata 30 | 31 | # other values from the config, defined by the needs of env.py, 32 | # can be acquired: 33 | # my_important_option = config.get_main_option("my_important_option") 34 | # ... etc. 35 | 36 | 37 | def run_migrations_offline(): 38 | """Run migrations in 'offline' mode. 39 | 40 | This configures the context with just a URL 41 | and not an Engine, though an Engine is acceptable 42 | here as well. By skipping the Engine creation 43 | we don't even need a DBAPI to be available. 44 | 45 | Calls to context.execute() here emit the given string to the 46 | script output. 47 | 48 | """ 49 | url = config.get_main_option("sqlalchemy.url") 50 | context.configure(url=url) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online(): 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | 64 | # this callback is used to prevent an auto-migration from being generated 65 | # when there are no changes to the schema 66 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 67 | def process_revision_directives(context, revision, directives): 68 | if getattr(config.cmd_opts, 'autogenerate', False): 69 | script = directives[0] 70 | if script.upgrade_ops.is_empty(): 71 | directives[:] = [] 72 | logger.info('No changes in schema detected.') 73 | 74 | engine = engine_from_config(config.get_section(config.config_ini_section), 75 | prefix='sqlalchemy.', 76 | poolclass=pool.NullPool) 77 | 78 | connection = engine.connect() 79 | context.configure(connection=connection, 80 | target_metadata=target_metadata, 81 | process_revision_directives=process_revision_directives, 82 | **current_app.extensions['migrate'].configure_args) 83 | 84 | try: 85 | with context.begin_transaction(): 86 | context.run_migrations() 87 | finally: 88 | connection.close() 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | run_migrations_online() 94 | -------------------------------------------------------------------------------- /migrations/initial_development_data.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring 3 | """ 4 | This file contains initialization data for development usage only. 5 | 6 | You can execute this code via ``invoke app.db.init_development_data`` 7 | """ 8 | from app.extensions import db, api 9 | 10 | from app.modules.users.models import User 11 | from app.modules.auth.models import OAuth2Client 12 | 13 | 14 | def init_users(): 15 | with db.session.begin(): 16 | root_user = User( 17 | username='root', 18 | email='root@localhost', 19 | password='q', 20 | is_active=True, 21 | is_regular_user=True, 22 | is_admin=True 23 | ) 24 | db.session.add(root_user) 25 | docs_user = User( 26 | username='documentation', 27 | email='documentation@localhost', 28 | password='w', 29 | is_active=False 30 | ) 31 | db.session.add(docs_user) 32 | regular_user = User( 33 | username='user', 34 | email='user@localhost', 35 | password='w', 36 | is_active=True, 37 | is_regular_user=True 38 | ) 39 | db.session.add(regular_user) 40 | internal_user = User( 41 | username='internal', 42 | email='internal@localhost', 43 | password='q', 44 | is_active=True, 45 | is_internal=True 46 | ) 47 | db.session.add(internal_user) 48 | return root_user, docs_user, regular_user 49 | 50 | def init_auth(docs_user): 51 | # TODO: OpenAPI documentation has to have OAuth2 Implicit Flow instead 52 | # of Resource Owner Password Credentials Flow 53 | with db.session.begin(): 54 | oauth2_client = OAuth2Client( 55 | client_id='documentation', 56 | client_secret='KQ()SWK)SQK)QWSKQW(SKQ)S(QWSQW(SJ*HQ&HQW*SQ*^SSQWSGQSG', 57 | user_id=docs_user.id, 58 | redirect_uris=[], 59 | default_scopes=api.api_v1.authorizations['oauth2_password']['scopes'] 60 | ) 61 | db.session.add(oauth2_client) 62 | return oauth2_client 63 | 64 | def init(): 65 | # Automatically update `default_scopes` for `documentation` OAuth2 Client, 66 | # as it is nice to have an ability to evaluate all available API calls. 67 | with db.session.begin(): 68 | OAuth2Client.query.filter(OAuth2Client.client_id == 'documentation').update({ 69 | OAuth2Client.default_scopes: api.api_v1.authorizations['oauth2_password']['scopes'], 70 | }) 71 | 72 | assert User.query.count() == 0, \ 73 | "Database is not empty. You should not re-apply fixtures! Aborted." 74 | 75 | root_user, docs_user, regular_user = init_users() # pylint: disable=unused-variable 76 | init_auth(docs_user) 77 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/15f27bc43bd_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 15f27bc43bd 4 | Revises: None 5 | Create Date: 2015-11-10 18:41:49.419188 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '15f27bc43bd' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('user', 20 | sa.Column('id', sa.Integer(), nullable=False), 21 | sa.Column('username', sa.String(length=80), nullable=False), 22 | sa.Column('password', sa.String(length=128), nullable=False), 23 | sa.Column('email', sa.String(length=120), nullable=False), 24 | sa.Column('first_name', sa.String(length=30), nullable=False), 25 | sa.Column('middle_name', sa.String(length=30), nullable=False), 26 | sa.Column('last_name', sa.String(length=30), nullable=False), 27 | sa.Column('static_roles', sa.Integer(), nullable=False), 28 | sa.PrimaryKeyConstraint('id'), 29 | sa.UniqueConstraint('email'), 30 | sa.UniqueConstraint('username') 31 | ) 32 | op.create_table('oauth2_client', 33 | sa.Column('client_id', sa.String(length=40), nullable=False), 34 | sa.Column('client_secret', sa.String(length=55), nullable=False), 35 | sa.Column('user_id', sa.Integer(), nullable=False), 36 | sa.Column('_redirect_uris', sa.Text(), nullable=False), 37 | sa.Column('_default_scopes', sa.Text(), nullable=False), 38 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), 39 | sa.PrimaryKeyConstraint('client_id') 40 | ) 41 | op.create_index(op.f('ix_oauth2_client_user_id'), 'oauth2_client', ['user_id'], unique=False) 42 | op.create_table('oauth2_grant', 43 | sa.Column('id', sa.Integer(), nullable=False), 44 | sa.Column('user_id', sa.Integer(), nullable=False), 45 | sa.Column('client_id', sa.String(length=40), nullable=False), 46 | sa.Column('code', sa.String(length=255), nullable=False), 47 | sa.Column('redirect_uri', sa.String(length=255), nullable=False), 48 | sa.Column('expires', sa.DateTime(), nullable=False), 49 | sa.Column('_scopes', sa.Text(), nullable=False), 50 | sa.ForeignKeyConstraint(['client_id'], ['oauth2_client.client_id'], ), 51 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), 52 | sa.PrimaryKeyConstraint('id') 53 | ) 54 | op.create_index(op.f('ix_oauth2_grant_client_id'), 'oauth2_grant', ['client_id'], unique=False) 55 | op.create_index(op.f('ix_oauth2_grant_code'), 'oauth2_grant', ['code'], unique=False) 56 | op.create_index(op.f('ix_oauth2_grant_user_id'), 'oauth2_grant', ['user_id'], unique=False) 57 | op.create_table('oauth2_token', 58 | sa.Column('id', sa.Integer(), nullable=False), 59 | sa.Column('client_id', sa.String(length=40), nullable=False), 60 | sa.Column('user_id', sa.Integer(), nullable=False), 61 | sa.Column('token_type', sa.String(length=40), nullable=False), 62 | sa.Column('access_token', sa.String(length=255), nullable=False), 63 | sa.Column('refresh_token', sa.String(length=255), nullable=True), 64 | sa.Column('expires', sa.DateTime(), nullable=False), 65 | sa.Column('_scopes', sa.Text(), nullable=False), 66 | sa.ForeignKeyConstraint(['client_id'], ['oauth2_client.client_id'], ), 67 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), 68 | sa.PrimaryKeyConstraint('id'), 69 | sa.UniqueConstraint('access_token'), 70 | sa.UniqueConstraint('refresh_token') 71 | ) 72 | op.create_index(op.f('ix_oauth2_token_client_id'), 'oauth2_token', ['client_id'], unique=False) 73 | op.create_index(op.f('ix_oauth2_token_user_id'), 'oauth2_token', ['user_id'], unique=False) 74 | ### end Alembic commands ### 75 | 76 | 77 | def downgrade(): 78 | ### commands auto generated by Alembic - please adjust! ### 79 | op.drop_index(op.f('ix_oauth2_token_user_id'), table_name='oauth2_token') 80 | op.drop_index(op.f('ix_oauth2_token_client_id'), table_name='oauth2_token') 81 | op.drop_table('oauth2_token') 82 | op.drop_index(op.f('ix_oauth2_grant_user_id'), table_name='oauth2_grant') 83 | op.drop_index(op.f('ix_oauth2_grant_code'), table_name='oauth2_grant') 84 | op.drop_index(op.f('ix_oauth2_grant_client_id'), table_name='oauth2_grant') 85 | op.drop_table('oauth2_grant') 86 | op.drop_index(op.f('ix_oauth2_client_user_id'), table_name='oauth2_client') 87 | op.drop_table('oauth2_client') 88 | op.drop_table('user') 89 | ### end Alembic commands ### 90 | -------------------------------------------------------------------------------- /migrations/versions/2b5af066bb9_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2b5af066bb9 4 | Revises: 2e9d99288cd 5 | Create Date: 2015-11-25 22:16:31.864584 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2b5af066bb9' 11 | down_revision = '2e9d99288cd' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | op.create_table('team', 20 | sa.Column('created', sa.DateTime(), nullable=False), 21 | sa.Column('updated', sa.DateTime(), nullable=False), 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('title', sa.String(length=50), nullable=False), 24 | sa.PrimaryKeyConstraint('id') 25 | ) 26 | op.create_table('team_members', 27 | sa.Column('team_id', sa.Integer(), nullable=True), 28 | sa.Column('user_id', sa.Integer(), nullable=True), 29 | sa.ForeignKeyConstraint(['team_id'], ['team.id'], ), 30 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ) 31 | ) 32 | ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | ### commands auto generated by Alembic - please adjust! ### 37 | op.drop_table('team_members') 38 | op.drop_table('team') 39 | ### end Alembic commands ### 40 | -------------------------------------------------------------------------------- /migrations/versions/2e9d99288cd_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 2e9d99288cd 4 | Revises: 36954739c63 5 | Create Date: 2015-11-23 21:16:54.103342 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '2e9d99288cd' 11 | down_revision = '36954739c63' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | ### commands auto generated by Alembic - please adjust! ### 19 | with op.batch_alter_table('user') as batch_op: 20 | batch_op.alter_column('created', 21 | existing_type=sa.DATETIME(), 22 | nullable=False) 23 | batch_op.alter_column('updated', 24 | existing_type=sa.DATETIME(), 25 | nullable=False) 26 | ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | ### commands auto generated by Alembic - please adjust! ### 31 | with op.batch_alter_table('user') as batch_op: 32 | batch_op.alter_column('updated', 33 | existing_type=sa.DATETIME(), 34 | nullable=True) 35 | batch_op.alter_column('created', 36 | existing_type=sa.DATETIME(), 37 | nullable=True) 38 | ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /migrations/versions/357c2809db4_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 357c2809db4 4 | Revises: 4754e1427ac 5 | Create Date: 2015-11-27 20:22:12.644342 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '357c2809db4' 11 | down_revision = '4754e1427ac' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | with op.batch_alter_table('team_member') as batch_op: 19 | batch_op.alter_column('is_leader', 20 | existing_type=sa.BOOLEAN(), 21 | nullable=False) 22 | 23 | 24 | def downgrade(): 25 | with op.batch_alter_table('team_member') as batch_op: 26 | batch_op.alter_column('is_leader', 27 | existing_type=sa.BOOLEAN(), 28 | nullable=True) 29 | -------------------------------------------------------------------------------- /migrations/versions/36954739c63_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 36954739c63 4 | Revises: 15f27bc43bd 5 | Create Date: 2015-11-23 21:00:24.105026 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '36954739c63' 11 | down_revision = '15f27bc43bd' 12 | 13 | from datetime import datetime 14 | 15 | from alembic import op 16 | import sqlalchemy as sa 17 | import sqlalchemy_utils 18 | 19 | 20 | def upgrade(): 21 | ### commands auto generated by Alembic - please adjust! ### 22 | op.add_column('user', sa.Column('created', sa.DateTime(), nullable=True)) 23 | op.add_column('user', sa.Column('updated', sa.DateTime(), nullable=True)) 24 | with op.batch_alter_table('user') as batch_op: 25 | batch_op.alter_column('password', 26 | existing_type=sa.VARCHAR(length=128), 27 | type_=sqlalchemy_utils.types.password.PasswordType(max_length=128), 28 | existing_nullable=False, 29 | postgresql_using='password::bytea') 30 | ### end Alembic commands ### 31 | 32 | user = sa.Table('user', 33 | sa.MetaData(), 34 | sa.Column('created', sa.DateTime()), 35 | sa.Column('updated', sa.DateTime()), 36 | ) 37 | 38 | op.execute( 39 | user.update().values({'created': datetime.now(), 'updated': datetime.now()}) 40 | ) 41 | 42 | 43 | def downgrade(): 44 | ### commands auto generated by Alembic - please adjust! ### 45 | with op.batch_alter_table('user') as batch_op: 46 | batch_op.alter_column('password', 47 | existing_type=sqlalchemy_utils.types.password.PasswordType(max_length=128), 48 | type_=sa.VARCHAR(length=128), 49 | existing_nullable=False) 50 | batch_op.drop_column('updated') 51 | batch_op.drop_column('created') 52 | ### end Alembic commands ### 53 | -------------------------------------------------------------------------------- /migrations/versions/4754e1427ac_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 4754e1427ac 4 | Revises: 2b5af066bb9 5 | Create Date: 2015-11-27 19:43:31.118013 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '4754e1427ac' 11 | down_revision = '2b5af066bb9' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | import sqlalchemy_utils 16 | 17 | 18 | def upgrade(): 19 | op.rename_table('team_members', 'team_member') 20 | op.add_column('team_member', sa.Column('is_leader', sa.Boolean(), nullable=True)) 21 | 22 | 23 | def downgrade(): 24 | with op.batch_alter_table('team_member') as batch_op: 25 | batch_op.drop_column('is_leader') 26 | op.rename_table('team_member', 'team_members') 27 | -------------------------------------------------------------------------------- /migrations/versions/5e2954a2af18_refactored-auth-oauth2.py: -------------------------------------------------------------------------------- 1 | """Refactored auth.OAuth2 models 2 | 3 | Revision ID: 5e2954a2af18 4 | Revises: 81ce4ac01c45 5 | Create Date: 2016-11-10 16:45:41.153837 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '5e2954a2af18' 11 | down_revision = '81ce4ac01c45' 12 | 13 | import enum 14 | 15 | from alembic import op 16 | import sqlalchemy as sa 17 | import sqlalchemy_utils 18 | 19 | 20 | OAuth2Client = sa.Table( 21 | 'oauth2_client', 22 | sa.MetaData(), 23 | sa.Column('default_scopes', sa.String), 24 | sa.Column('_default_scopes', sa.String), 25 | sa.Column('redirect_uris', sa.String), 26 | sa.Column('_redirect_uris', sa.String), 27 | ) 28 | 29 | OAuth2Grant = sa.Table( 30 | 'oauth2_grant', 31 | sa.MetaData(), 32 | sa.Column('scopes', sa.String), 33 | sa.Column('_scopes', sa.String), 34 | ) 35 | 36 | OAuth2Token = sa.Table( 37 | 'oauth2_token', 38 | sa.MetaData(), 39 | sa.Column('scopes', sa.String), 40 | sa.Column('_scopes', sa.String), 41 | ) 42 | 43 | 44 | def upgrade(): 45 | connection = op.get_bind() 46 | 47 | clienttypes = sa.dialects.postgresql.ENUM('public', 'confidential', name='clienttypes') 48 | clienttypes.create(connection) 49 | 50 | with op.batch_alter_table('oauth2_client') as batch_op: 51 | batch_op.add_column( 52 | sa.Column( 53 | 'client_type', 54 | sa.Enum('public', 'confidential', name='clienttypes'), 55 | server_default='public', 56 | nullable=False 57 | ) 58 | ) 59 | batch_op.add_column( 60 | sa.Column( 61 | 'default_scopes', 62 | sqlalchemy_utils.types.scalar_list.ScalarListType(), 63 | server_default='', 64 | nullable=False 65 | ) 66 | ) 67 | batch_op.add_column( 68 | sa.Column( 69 | 'redirect_uris', 70 | sqlalchemy_utils.types.scalar_list.ScalarListType(), 71 | server_default='', 72 | nullable=False 73 | ) 74 | ) 75 | 76 | connection.execute( 77 | OAuth2Client.update().values(default_scopes=OAuth2Client.c._default_scopes) 78 | ) 79 | connection.execute( 80 | OAuth2Client.update().values(redirect_uris=OAuth2Client.c._redirect_uris) 81 | ) 82 | 83 | with op.batch_alter_table('oauth2_client') as batch_op: 84 | batch_op.drop_column('_redirect_uris') 85 | batch_op.drop_column('_default_scopes') 86 | batch_op.alter_column('redirect_uris', server_default=None) 87 | batch_op.alter_column('default_scopes', server_default=None) 88 | 89 | with op.batch_alter_table('oauth2_grant') as batch_op: 90 | batch_op.add_column( 91 | sa.Column( 92 | 'scopes', 93 | sqlalchemy_utils.types.scalar_list.ScalarListType(), 94 | server_default='', 95 | nullable=False 96 | ) 97 | ) 98 | 99 | connection.execute( 100 | OAuth2Grant.update().values(scopes=OAuth2Grant.c._scopes) 101 | ) 102 | 103 | with op.batch_alter_table('oauth2_grant') as batch_op: 104 | batch_op.drop_column('_scopes') 105 | batch_op.alter_column('scopes', server_default=None) 106 | 107 | with op.batch_alter_table('oauth2_token') as batch_op: 108 | batch_op.add_column( 109 | sa.Column( 110 | 'scopes', 111 | sqlalchemy_utils.types.scalar_list.ScalarListType(), 112 | server_default='', 113 | nullable=False 114 | ) 115 | ) 116 | 117 | connection.execute( 118 | OAuth2Token.update().values(scopes=OAuth2Token.c._scopes) 119 | ) 120 | 121 | with op.batch_alter_table('oauth2_token') as batch_op: 122 | batch_op.drop_column('_scopes') 123 | batch_op.alter_column('scopes', server_default=None) 124 | 125 | 126 | def downgrade(): 127 | connection = op.get_bind() 128 | 129 | with op.batch_alter_table('oauth2_token') as batch_op: 130 | batch_op.add_column(sa.Column('_scopes', sa.TEXT(), server_default='', nullable=False)) 131 | 132 | connection.execute( 133 | OAuth2Token.update().values(_scopes=OAuth2Token.c.scopes) 134 | ) 135 | 136 | with op.batch_alter_table('oauth2_token') as batch_op: 137 | batch_op.drop_column('scopes') 138 | 139 | with op.batch_alter_table('oauth2_grant') as batch_op: 140 | batch_op.add_column(sa.Column('_scopes', sa.TEXT(), server_default='', nullable=False)) 141 | 142 | connection.execute( 143 | OAuth2Grant.update().values(_scopes=OAuth2Grant.c.scopes) 144 | ) 145 | 146 | with op.batch_alter_table('oauth2_grant') as batch_op: 147 | batch_op.drop_column('scopes') 148 | 149 | with op.batch_alter_table('oauth2_client') as batch_op: 150 | batch_op.add_column( 151 | sa.Column( 152 | '_default_scopes', 153 | sa.TEXT(), 154 | server_default='', 155 | nullable=False 156 | ) 157 | ) 158 | batch_op.add_column( 159 | sa.Column( 160 | '_redirect_uris', 161 | sa.TEXT(), 162 | server_default='', 163 | nullable=False 164 | ) 165 | ) 166 | 167 | connection.execute( 168 | OAuth2Client.update().values(_default_scopes=OAuth2Client.c.default_scopes) 169 | ) 170 | connection.execute( 171 | OAuth2Client.update().values(_redirect_uris=OAuth2Client.c.redirect_uris) 172 | ) 173 | 174 | with op.batch_alter_table('oauth2_client') as batch_op: 175 | batch_op.drop_column('redirect_uris') 176 | batch_op.drop_column('default_scopes') 177 | batch_op.drop_column('client_type') 178 | 179 | clienttypes = sa.dialects.postgresql.ENUM('public', 'confidential', name='clienttypes') 180 | clienttypes.drop(connection) 181 | -------------------------------------------------------------------------------- /migrations/versions/81ce4ac01c45_migrate_static_roles.py: -------------------------------------------------------------------------------- 1 | """Migrate static roles (new "internal" role type requires data migration) 2 | 3 | Revision ID: 81ce4ac01c45 4 | Revises: beb065460c24 5 | Create Date: 2016-11-08 15:58:55.932297 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '81ce4ac01c45' 11 | down_revision = 'beb065460c24' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | UserHelper = sa.Table( 17 | 'user', 18 | sa.MetaData(), 19 | sa.Column('id', sa.Integer, primary_key=True), 20 | sa.Column('static_roles', sa.Integer), 21 | ) 22 | 23 | def upgrade(): 24 | connection = op.get_bind() 25 | for user in connection.execute(UserHelper.select()): 26 | if user.static_roles & 0x1000: 27 | continue 28 | new_static_roles = user.static_roles >> 1 29 | connection.execute( 30 | UserHelper.update().where( 31 | UserHelper.c.id == user.id 32 | ).values( 33 | static_roles=new_static_roles 34 | ) 35 | ) 36 | 37 | def downgrade(): 38 | connection = op.get_bind() 39 | for user in connection.execute(UserHelper.select()): 40 | if not user.static_roles & 0x1000: 41 | continue 42 | new_static_roles = user.static_roles << 1 43 | connection.execute( 44 | UserHelper.update().where( 45 | UserHelper.c.id == user.id 46 | ).values( 47 | static_roles=new_static_roles 48 | ) 49 | ) 50 | -------------------------------------------------------------------------------- /migrations/versions/82184d7d1e88_altered-OAuth2Token-token_type-to-Enum.py: -------------------------------------------------------------------------------- 1 | """Alter OAuth2Token.token_type to Enum 2 | 3 | Revision ID: 82184d7d1e88 4 | Revises: 5e2954a2af18 5 | Create Date: 2016-11-10 21:14:33.787194 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '82184d7d1e88' 11 | down_revision = '5e2954a2af18' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | connection = op.get_bind() 19 | 20 | 21 | with op.batch_alter_table('oauth2_token') as batch_op: 22 | tokentypes = sa.dialects.postgresql.ENUM('Bearer', name='tokentypes') 23 | tokentypes.create(connection) 24 | 25 | batch_op.alter_column('token_type', 26 | existing_type=sa.VARCHAR(length=40), 27 | type_=sa.Enum('Bearer', name='tokentypes'), 28 | existing_nullable=False, 29 | postgresql_using='token_type::tokentypes') 30 | 31 | 32 | def downgrade(): 33 | connection = op.get_bind() 34 | 35 | with op.batch_alter_table('oauth2_token') as batch_op: 36 | batch_op.alter_column('token_type', 37 | existing_type=sa.Enum('Bearer', name='tokentypes'), 38 | type_=sa.VARCHAR(length=40), 39 | existing_nullable=False) 40 | 41 | tokentypes = sa.dialects.postgresql.ENUM('Bearer', name='tokentypes') 42 | tokentypes.drop(connection) 43 | -------------------------------------------------------------------------------- /migrations/versions/8c8b2d23a5_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 8c8b2d23a5 4 | Revises: 357c2809db4 5 | Create Date: 2015-11-27 20:43:11.241948 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '8c8b2d23a5' 11 | down_revision = '357c2809db4' 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | with op.batch_alter_table('team_member') as batch_op: 19 | batch_op.create_unique_constraint('_team_user_uc', ['team_id', 'user_id']) 20 | 21 | 22 | def downgrade(): 23 | with op.batch_alter_table('team_member') as batch_op: 24 | batch_op.drop_constraint('_team_user_uc', type_='unique') 25 | -------------------------------------------------------------------------------- /migrations/versions/beb065460c24_fixed-password-type.py: -------------------------------------------------------------------------------- 1 | """Upgraded to the correct PasswordType implementation 2 | https://github.com/kvesteri/sqlalchemy-utils/pull/254 3 | 4 | Revision ID: beb065460c24 5 | Revises: 8c8b2d23a5 6 | Create Date: 2016-11-09 09:10:40.630496 7 | 8 | """ 9 | 10 | # revision identifiers, used by Alembic. 11 | revision = 'beb065460c24' 12 | down_revision = '8c8b2d23a5' 13 | 14 | from alembic import op 15 | import sqlalchemy as sa 16 | import sqlalchemy_utils 17 | 18 | 19 | UserHelper = sa.Table( 20 | 'user', 21 | sa.MetaData(), 22 | sa.Column('id', sa.Integer, primary_key=True), 23 | sa.Column('password', sa.String), 24 | sa.Column('_password', sa.String), 25 | ) 26 | 27 | def upgrade(): 28 | connection = op.get_bind() 29 | if connection.engine.name != 'sqlite': 30 | return 31 | 32 | with op.batch_alter_table('user') as batch_op: 33 | batch_op.add_column(sa.Column('_password', 34 | sqlalchemy_utils.types.password.PasswordType(max_length=128), 35 | server_default='', 36 | nullable=False 37 | )) 38 | 39 | connection.execute( 40 | UserHelper.update().values(_password=UserHelper.c.password) 41 | ) 42 | 43 | with op.batch_alter_table('user') as batch_op: 44 | batch_op.drop_column('password') 45 | batch_op.alter_column('_password', server_default=None, new_column_name='password') 46 | 47 | 48 | def downgrade(): 49 | connection = op.get_bind() 50 | if connection.engine.name != 'sqlite': 51 | return 52 | 53 | with op.batch_alter_table('user') as batch_op: 54 | batch_op.add_column(sa.Column('_password', 55 | type_=sa.NUMERIC(precision=128), 56 | server_default='', 57 | nullable=False 58 | )) 59 | 60 | connection.execute( 61 | UserHelper.update().values(_password=UserHelper.c.password) 62 | ) 63 | 64 | with op.batch_alter_table('user') as batch_op: 65 | batch_op.drop_column('password') 66 | batch_op.alter_column('_password', server_default=None, new_column_name='password') 67 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r tasks/requirements.txt 2 | -r app/requirements.txt 3 | -------------------------------------------------------------------------------- /tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=invalid-name,wrong-import-position 3 | """ 4 | The starting point of Invoke tasks for Example RESTful API Server project. 5 | """ 6 | 7 | import logging 8 | import os 9 | import platform 10 | import sys 11 | import sysconfig 12 | 13 | logging.basicConfig() 14 | logger = logging.getLogger() 15 | logger.setLevel(logging.INFO) 16 | #logging.getLogger('app').setLevel(logging.DEBUG) 17 | 18 | try: 19 | import colorlog 20 | except ImportError: 21 | pass 22 | else: 23 | formatter = colorlog.ColoredFormatter( 24 | ( 25 | '%(asctime)s ' 26 | '[%(log_color)s%(levelname)s%(reset)s] ' 27 | '[%(cyan)s%(name)s%(reset)s] ' 28 | '%(message_log_color)s%(message)s' 29 | ), 30 | reset=True, 31 | log_colors={ 32 | 'DEBUG': 'bold_cyan', 33 | 'INFO': 'bold_green', 34 | 'WARNING': 'bold_yellow', 35 | 'ERROR': 'bold_red', 36 | 'CRITICAL': 'bold_red,bg_white', 37 | }, 38 | secondary_log_colors={ 39 | 'message': { 40 | 'DEBUG': 'white', 41 | 'INFO': 'bold_white', 42 | 'WARNING': 'bold_yellow', 43 | 'ERROR': 'bold_red', 44 | 'CRITICAL': 'bold_red', 45 | }, 46 | }, 47 | style='%' 48 | ) 49 | 50 | for handler in logger.handlers: 51 | if isinstance(handler, logging.StreamHandler): 52 | break 53 | else: 54 | handler = logging.StreamHandler() 55 | logger.addHandler(handler) 56 | handler.setFormatter(formatter) 57 | 58 | 59 | from invoke import Collection 60 | from invoke.executor import Executor 61 | 62 | from . import app 63 | 64 | # NOTE: `namespace` or `ns` name is required! 65 | namespace = Collection( 66 | app, 67 | ) 68 | 69 | def invoke_execute(context, command_name, **kwargs): 70 | """ 71 | Helper function to make invoke-tasks execution easier. 72 | """ 73 | results = Executor(namespace, config=context.config).execute((command_name, kwargs)) 74 | target_task = context.root_namespace[command_name] 75 | return results[target_task] 76 | 77 | namespace.configure({ 78 | 'run': { 79 | 'shell': '/bin/sh' if platform.system() != 'Windows' else os.environ.get('COMSPEC'), 80 | }, 81 | 'root_namespace': namespace, 82 | 'invoke_execute': invoke_execute, 83 | }) 84 | -------------------------------------------------------------------------------- /tasks/app/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Application related tasks for Invoke. 4 | """ 5 | 6 | from invoke import Collection 7 | 8 | from . import dependencies, env, db, run, users, swagger 9 | 10 | from config import BaseConfig 11 | 12 | namespace = Collection( 13 | dependencies, 14 | env, 15 | db, 16 | run, 17 | users, 18 | swagger, 19 | ) 20 | 21 | namespace.configure({ 22 | 'app': { 23 | 'static_root': BaseConfig.STATIC_ROOT, 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /tasks/app/_utils.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Invoke tasks utilities for apps. 4 | """ 5 | import functools 6 | 7 | from invoke import Task as BaseTask 8 | 9 | 10 | class Task(BaseTask): 11 | """ 12 | A patched Invoke Task adding support for decorated functions. 13 | """ 14 | def __init__(self, *args, **kwargs): 15 | super(Task, self).__init__(*args, **kwargs) 16 | # Make these tasks always contextualized (this is the only option in 17 | # Invoke >=0.13), so we just backport this default on Invoke 0.12. 18 | self.contextualized = True 19 | 20 | def argspec(self, body): 21 | """ 22 | See details in https://github.com/pyinvoke/invoke/pull/399. 23 | """ 24 | if hasattr(body, '__wrapped__'): 25 | return self.argspec(body.__wrapped__) 26 | return super(Task, self).argspec(body) 27 | 28 | 29 | def app_context_task(*args, **kwargs): 30 | """ 31 | A helper Invoke Task decorator with auto app context activation. 32 | 33 | Examples: 34 | 35 | >>> @app_context_task 36 | ... def my_task(context, some_arg, some_option='default'): 37 | ... print("Done") 38 | 39 | >>> @app_context_task( 40 | ... help={'some_arg': "This is something useful"} 41 | ... ) 42 | ... def my_task(context, some_arg, some_option='default'): 43 | ... print("Done") 44 | """ 45 | if len(args) == 1: 46 | func = args[0] 47 | 48 | @functools.wraps(func) 49 | def wrapper(*args, **kwargs): 50 | """ 51 | A wrapped which tries to get ``app`` from ``kwargs`` or creates a 52 | new ``app`` otherwise, and actives the application context, so the 53 | decorated function is run inside the application context. 54 | """ 55 | app = kwargs.pop('app', None) 56 | if app is None: 57 | from app import create_app 58 | app = create_app() 59 | 60 | with app.app_context(): 61 | return func(*args, **kwargs) 62 | 63 | # This is the default in Python 3, so we just make it backwards 64 | # compatible with Python 2 65 | if not hasattr(wrapper, '__wrapped__'): 66 | wrapper.__wrapped__ = func 67 | return Task(wrapper, **kwargs) 68 | 69 | return lambda func: app_context_task(func, **kwargs) 70 | -------------------------------------------------------------------------------- /tasks/app/db_templates/flask/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | -------------------------------------------------------------------------------- /tasks/app/db_templates/flask/alembic.ini.mako: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = sqlalchemy,alembic 15 | #keys = root,sqlalchemy,alembic 16 | 17 | #[handlers] 18 | #keys = console 19 | 20 | #[formatters] 21 | #keys = generic 22 | 23 | #[logger_root] 24 | #level = WARN 25 | #handlers = console 26 | #qualname = 27 | 28 | [logger_sqlalchemy] 29 | level = WARN 30 | handlers = 31 | qualname = sqlalchemy.engine 32 | 33 | [logger_alembic] 34 | level = INFO 35 | handlers = 36 | qualname = alembic 37 | 38 | #[handler_console] 39 | #class = StreamHandler 40 | #args = (sys.stderr,) 41 | #level = NOTSET 42 | #formatter = generic 43 | 44 | #[formatter_generic] 45 | #format = %(levelname)-5.5s [%(name)s] %(message)s 46 | #datefmt = %H:%M:%S 47 | -------------------------------------------------------------------------------- /tasks/app/db_templates/flask/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Setup logging 12 | sqlalchemy_logger = logging.getLogger('sqlalchemy') 13 | if sqlalchemy_logger.level == logging.NOTSET: 14 | sqlalchemy_logger.setLevel(logging.WARN) 15 | 16 | alembic_logger = logging.getLogger('alembic') 17 | if alembic_logger.level == logging.NOTSET: 18 | alembic_logger.setLevel(logging.INFO) 19 | 20 | logger = logging.getLogger('alembic.env') 21 | 22 | # add your model's MetaData object here 23 | # for 'autogenerate' support 24 | # from myapp import mymodel 25 | # target_metadata = mymodel.Base.metadata 26 | from flask import current_app 27 | config.set_main_option('sqlalchemy.url', 28 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 29 | target_metadata = current_app.extensions['migrate'].db.metadata 30 | 31 | # other values from the config, defined by the needs of env.py, 32 | # can be acquired: 33 | # my_important_option = config.get_main_option("my_important_option") 34 | # ... etc. 35 | 36 | 37 | def run_migrations_offline(): 38 | """Run migrations in 'offline' mode. 39 | 40 | This configures the context with just a URL 41 | and not an Engine, though an Engine is acceptable 42 | here as well. By skipping the Engine creation 43 | we don't even need a DBAPI to be available. 44 | 45 | Calls to context.execute() here emit the given string to the 46 | script output. 47 | 48 | """ 49 | url = config.get_main_option("sqlalchemy.url") 50 | context.configure(url=url) 51 | 52 | with context.begin_transaction(): 53 | context.run_migrations() 54 | 55 | 56 | def run_migrations_online(): 57 | """Run migrations in 'online' mode. 58 | 59 | In this scenario we need to create an Engine 60 | and associate a connection with the context. 61 | 62 | """ 63 | 64 | # this callback is used to prevent an auto-migration from being generated 65 | # when there are no changes to the schema 66 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 67 | def process_revision_directives(context, revision, directives): 68 | if getattr(config.cmd_opts, 'autogenerate', False): 69 | script = directives[0] 70 | if script.upgrade_ops.is_empty(): 71 | directives[:] = [] 72 | logger.info('No changes in schema detected.') 73 | 74 | engine = engine_from_config(config.get_section(config.config_ini_section), 75 | prefix='sqlalchemy.', 76 | poolclass=pool.NullPool) 77 | 78 | connection = engine.connect() 79 | context.configure(connection=connection, 80 | target_metadata=target_metadata, 81 | process_revision_directives=process_revision_directives, 82 | **current_app.extensions['migrate'].configure_args) 83 | 84 | try: 85 | with context.begin_transaction(): 86 | context.run_migrations() 87 | finally: 88 | connection.close() 89 | 90 | if context.is_offline_mode(): 91 | run_migrations_offline() 92 | else: 93 | run_migrations_online() 94 | -------------------------------------------------------------------------------- /tasks/app/db_templates/flask/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /tasks/app/dependencies.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Application dependencies related tasks for Invoke. 4 | """ 5 | import logging 6 | import os 7 | import shutil 8 | import zipfile 9 | 10 | try: 11 | from invoke import ctask as task 12 | except ImportError: # Invoke 0.13 renamed ctask to task 13 | from invoke import task 14 | 15 | from tasks.utils import download_file 16 | 17 | 18 | log = logging.getLogger(__name__) # pylint: disable=invalid-name 19 | 20 | 21 | @task 22 | def install_python_dependencies(context, force=False): 23 | """ 24 | Install Python dependencies listed in requirements.txt. 25 | """ 26 | log.info("Installing project dependencies...") 27 | context.run("pip install -r requirements.txt %s" % ('--upgrade' if force else '')) 28 | log.info("Project dependencies are installed.") 29 | 30 | @task 31 | def install_swagger_ui(context, force=False): 32 | # pylint: disable=unused-argument 33 | """ 34 | Install Swagger UI HTML/JS/CSS assets. 35 | """ 36 | log.info("Installing Swagger UI assets...") 37 | 38 | try: 39 | FileExistsError 40 | except NameError: 41 | FileExistsError = OSError 42 | try: 43 | os.makedirs(os.path.join(context.app.static_root, 'bower')) 44 | except FileExistsError: 45 | pass 46 | 47 | swagger_ui_zip_filepath = os.path.join(context.app.static_root, 'bower', 'swagger-ui.zip') 48 | swagger_ui_root = os.path.join(context.app.static_root, 'bower', 'swagger-ui') 49 | 50 | if force: 51 | try: 52 | os.remove(swagger_ui_zip_filepath) 53 | except FileNotFoundError: 54 | pass 55 | try: 56 | shutil.rmtree(swagger_ui_root) 57 | except FileNotFoundError: 58 | pass 59 | 60 | # We are going to install Swagger UI from a fork which includes useful patches 61 | log.info("Downloading Swagger UI assets...") 62 | download_file( 63 | url="https://github.com/swagger-api/swagger-ui/archive/v2.2.10.zip", 64 | local_filepath=swagger_ui_zip_filepath 65 | ) 66 | 67 | # Unzip swagger-ui.zip/dist into swagger-ui folder 68 | log.info("Unpacking Swagger UI assets...") 69 | with zipfile.ZipFile(swagger_ui_zip_filepath) as swagger_ui_zip_file: 70 | for zipped_member in swagger_ui_zip_file.infolist(): 71 | zipped_member_path = os.path.relpath(zipped_member.filename, 'swagger-ui-2.2.10') 72 | 73 | # We only need the 'dist' folder 74 | try: 75 | commonpath = os.path.commonpath 76 | except: # Python 2.x fallback 77 | commonpath = os.path.commonprefix 78 | if not commonpath([zipped_member_path, 'dist']): 79 | continue 80 | 81 | extract_path = os.path.join(swagger_ui_root, zipped_member_path) 82 | if not os.path.split(zipped_member.filename)[1]: 83 | # If the path is folder, just create a folder 84 | try: 85 | os.makedirs(extract_path) 86 | except FileExistsError: 87 | pass 88 | else: 89 | # Otherwise, read zipped file contents and write them to a file 90 | with swagger_ui_zip_file.open(zipped_member) as zipped_file: 91 | with open(extract_path, mode='wb') as unzipped_file: 92 | unzipped_file.write(zipped_file.read()) 93 | 94 | log.info("Swagger UI is installed.") 95 | 96 | @task 97 | def install(context): 98 | # pylint: disable=unused-argument 99 | """ 100 | Install project dependencies. 101 | """ 102 | install_python_dependencies(context) 103 | install_swagger_ui(context) 104 | -------------------------------------------------------------------------------- /tasks/app/env.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Application environment related tasks for Invoke. 4 | """ 5 | 6 | try: 7 | from invoke import ctask as task 8 | except ImportError: # Invoke 0.13 renamed ctask to task 9 | from invoke import task 10 | 11 | 12 | @task 13 | def enter(context, install_dependencies=True, upgrade_db=True): 14 | """ 15 | Enter into IPython notebook shell with an initialized app. 16 | """ 17 | if install_dependencies: 18 | context.invoke_execute(context, 'app.dependencies.install') 19 | if upgrade_db: 20 | context.invoke_execute(context, 'app.db.upgrade') 21 | context.invoke_execute( 22 | context, 23 | 'app.db.init_development_data', 24 | upgrade_db=False, 25 | skip_on_failure=True 26 | ) 27 | 28 | 29 | import pprint 30 | import logging 31 | 32 | from werkzeug import script 33 | import flask 34 | 35 | import app 36 | flask_app = app.create_app() 37 | 38 | def shell_context(): 39 | context = dict(pprint=pprint.pprint) 40 | context.update(vars(flask)) 41 | context.update(vars(app)) 42 | return context 43 | 44 | with flask_app.app_context(): 45 | script.make_shell(shell_context, use_ipython=True)() 46 | -------------------------------------------------------------------------------- /tasks/app/run.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-many-arguments 3 | """ 4 | Application execution related tasks for Invoke. 5 | """ 6 | 7 | try: 8 | from importlib import reload 9 | except ImportError: 10 | pass # Python 2 has built-in reload() function 11 | import os 12 | import platform 13 | import warnings 14 | 15 | try: 16 | from invoke import ctask as task 17 | except ImportError: # Invoke 0.13 renamed ctask to task 18 | from invoke import task 19 | 20 | 21 | @task(default=True) 22 | def run( 23 | context, 24 | host='127.0.0.1', 25 | port=5000, 26 | flask_config=None, 27 | install_dependencies=True, 28 | upgrade_db=True 29 | ): 30 | """ 31 | Run Example RESTful API Server. 32 | """ 33 | if flask_config is not None: 34 | os.environ['FLASK_CONFIG'] = flask_config 35 | 36 | if install_dependencies: 37 | context.invoke_execute(context, 'app.dependencies.install') 38 | 39 | from app import create_app 40 | app = create_app() 41 | 42 | if upgrade_db: 43 | # After the installed dependencies the app.db.* tasks might need to be 44 | # reloaded to import all necessary dependencies. 45 | from . import db as db_tasks 46 | reload(db_tasks) 47 | 48 | context.invoke_execute(context, 'app.db.upgrade', app=app) 49 | if app.debug: 50 | context.invoke_execute( 51 | context, 52 | 'app.db.init_development_data', 53 | app=app, 54 | upgrade_db=False, 55 | skip_on_failure=True 56 | ) 57 | 58 | use_reloader = app.debug 59 | if platform.system() == 'Windows': 60 | warnings.warn( 61 | "Auto-reloader feature doesn't work on Windows. " 62 | "Follow the issue for more details: " 63 | "https://github.com/frol/flask-restplus-server-example/issues/16" 64 | ) 65 | use_reloader = False 66 | app.run(host=host, port=port, use_reloader=use_reloader) 67 | -------------------------------------------------------------------------------- /tasks/app/swagger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Swagger related invoke tasks 3 | """ 4 | import logging 5 | 6 | try: 7 | from invoke import ctask as task 8 | except ImportError: # Invoke 0.13 renamed ctask to task 9 | from invoke import task 10 | 11 | 12 | @task(default=True) 13 | def export(context): 14 | """ 15 | Export swagger.json content 16 | """ 17 | # set logging level to ERROR to avoid [INFO] messages in result 18 | logging.getLogger().setLevel(logging.ERROR) 19 | 20 | from app import create_app 21 | app = create_app() 22 | swagger_content = app.test_client().get('/api/v1/swagger.json').data 23 | print(swagger_content.decode('utf-8')) 24 | -------------------------------------------------------------------------------- /tasks/app/users.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Application Users management related tasks for Invoke. 4 | """ 5 | 6 | from ._utils import app_context_task 7 | 8 | 9 | @app_context_task(help={'username': "qwe"}) 10 | def create_user( 11 | context, 12 | username, 13 | email, 14 | is_internal=False, 15 | is_admin=False, 16 | is_regular_user=True, 17 | is_active=True 18 | ): 19 | """ 20 | Create a new user. 21 | """ 22 | from app.modules.users.models import User 23 | 24 | password = input("Enter password: ") 25 | 26 | new_user = User( 27 | username=username, 28 | password=password, 29 | email=email, 30 | is_internal=is_internal, 31 | is_admin=is_admin, 32 | is_regular_user=is_regular_user, 33 | is_active=is_active 34 | ) 35 | 36 | from app.extensions import db 37 | db.session.add(new_user) 38 | db.session.commit() 39 | 40 | 41 | @app_context_task 42 | def create_oauth2_client( 43 | context, 44 | username, 45 | client_id, 46 | client_secret, 47 | default_scopes=None 48 | ): 49 | """ 50 | Create a new OAuth2 Client associated with a given user (username). 51 | """ 52 | from app.modules.users.models import User 53 | from app.modules.auth.models import OAuth2Client 54 | 55 | user = User.query.first(User.username == username) 56 | if not user: 57 | raise Exception("User with username '%s' does not exist." % username) 58 | 59 | if default_scopes is None: 60 | from app.extensions.api import api_v1 61 | default_scopes = ' '.join(api_v1.authorizations['oauth2_password']['scopes']) 62 | 63 | oauth2_client = OAuth2Client( 64 | client_id=client_id, 65 | client_secret=client_secret, 66 | user=user, 67 | _default_scopes=default_scopes 68 | ) 69 | 70 | from app.extensions import db 71 | db.session.add(oauth2_client) 72 | db.session.commit() 73 | -------------------------------------------------------------------------------- /tasks/requirements.txt: -------------------------------------------------------------------------------- 1 | invoke 2 | colorlog 3 | lockfile 4 | requests 5 | -------------------------------------------------------------------------------- /tasks/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Invoke tasks helper functions 3 | ============================= 4 | """ 5 | import logging 6 | import os 7 | 8 | 9 | log = logging.getLogger(__name__) # pylint: disable=invalid-name 10 | 11 | 12 | def download_file( 13 | url, 14 | local_filepath, 15 | chunk_size=1024*512, 16 | lock_timeout=10, 17 | http_timeout=None, 18 | session=None 19 | ): 20 | # pylint: disable=too-many-arguments 21 | """ 22 | A helper function which can download a file from a specified ``url`` to a 23 | local file ``local_filepath`` in chunks and using a file lock to prevent 24 | a concurrent download of the same file. 25 | """ 26 | # Avoid unnecessary dependencies when the function is not used. 27 | import lockfile 28 | import requests 29 | 30 | log.debug("Checking file existance in '%s'", local_filepath) 31 | lock = lockfile.LockFile(local_filepath) 32 | try: 33 | lock.acquire(timeout=lock_timeout) 34 | except lockfile.LockTimeout: 35 | log.info( 36 | "File '%s' is locked. Probably another instance is still downloading it.", 37 | local_filepath 38 | ) 39 | raise 40 | try: 41 | if not os.path.exists(local_filepath): 42 | log.info("Downloading a file from '%s' to '%s'", url, local_filepath) 43 | if session is None: 44 | session = requests 45 | response = session.get(url, stream=True, timeout=http_timeout) 46 | if response.status_code != 200: 47 | log.error("Download '%s' is failed: %s", url, response) 48 | response.raise_for_status() 49 | with open(local_filepath, 'wb') as local_file: 50 | for chunk in response.iter_content(chunk_size=chunk_size): 51 | # filter out keep-alive new chunks 52 | if chunk: 53 | local_file.write(chunk) 54 | log.debug("File '%s' has been downloaded", local_filepath) 55 | return local_filepath 56 | finally: 57 | lock.release() 58 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | The Application tests collection 4 | ================================ 5 | """ 6 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import pytest 3 | 4 | from tests import utils 5 | 6 | from app import create_app 7 | 8 | 9 | @pytest.yield_fixture(scope='session') 10 | def flask_app(): 11 | app = create_app(flask_config_name='testing') 12 | from app.extensions import db 13 | 14 | with app.app_context(): 15 | db.create_all() 16 | yield app 17 | db.drop_all() 18 | 19 | 20 | @pytest.yield_fixture(scope='session') 21 | def db(flask_app): 22 | from app.extensions import db as db_instance 23 | yield db_instance 24 | 25 | 26 | @pytest.fixture(scope='session') 27 | def flask_app_client(flask_app): 28 | flask_app.test_client_class = utils.AutoAuthFlaskClient 29 | flask_app.response_class = utils.JSONResponse 30 | return flask_app.test_client() 31 | 32 | 33 | @pytest.yield_fixture(scope='session') 34 | def regular_user(db): 35 | regular_user_instance = utils.generate_user_instance( 36 | username='regular_user' 37 | ) 38 | 39 | with db.session.begin(): 40 | db.session.add(regular_user_instance) 41 | 42 | yield regular_user_instance 43 | 44 | with db.session.begin(): 45 | db.session.delete(regular_user_instance) 46 | 47 | 48 | @pytest.yield_fixture(scope='session') 49 | def readonly_user(db): 50 | readonly_user_instance = utils.generate_user_instance( 51 | username='readonly_user', 52 | is_regular_user=False 53 | ) 54 | 55 | with db.session.begin(): 56 | db.session.add(readonly_user_instance) 57 | 58 | yield readonly_user_instance 59 | 60 | with db.session.begin(): 61 | db.session.delete(readonly_user_instance) 62 | 63 | 64 | @pytest.yield_fixture(scope='session') 65 | def admin_user(db): 66 | admin_user_instance = utils.generate_user_instance( 67 | username='admin_user', 68 | is_admin=True 69 | ) 70 | 71 | with db.session.begin(): 72 | db.session.add(admin_user_instance) 73 | 74 | yield admin_user_instance 75 | 76 | with db.session.begin(): 77 | db.session.delete(admin_user_instance) 78 | 79 | 80 | @pytest.yield_fixture(scope='session') 81 | def internal_user(db): 82 | internal_user_instance = utils.generate_user_instance( 83 | username='internal_user', 84 | is_regular_user=False, 85 | is_admin=False, 86 | is_active=True, 87 | is_internal=True 88 | ) 89 | 90 | with db.session.begin(): 91 | db.session.add(internal_user_instance) 92 | 93 | yield internal_user_instance 94 | 95 | with db.session.begin(): 96 | db.session.delete(internal_user_instance) 97 | -------------------------------------------------------------------------------- /tests/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/extensions/__init__.py -------------------------------------------------------------------------------- /tests/extensions/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/extensions/api/__init__.py -------------------------------------------------------------------------------- /tests/extensions/api/test_api_versions_availability.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app.extensions import api 4 | 5 | 6 | @pytest.mark.parametrize('api_version', [ 7 | '1', 8 | ]) 9 | def test_extension_availability(api_version): 10 | assert hasattr(api, 'api_v%s' % api_version) 11 | -------------------------------------------------------------------------------- /tests/extensions/test_extensions_availability.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from app import extensions 4 | 5 | 6 | @pytest.mark.parametrize('extension_name', [ 7 | 'db', 8 | 'login_manager', 9 | 'marshmallow', 10 | 'api', 11 | 'oauth2', 12 | ]) 13 | def test_extension_availability(extension_name): 14 | assert hasattr(extensions, extension_name) 15 | -------------------------------------------------------------------------------- /tests/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/modules/__init__.py -------------------------------------------------------------------------------- /tests/modules/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/modules/auth/__init__.py -------------------------------------------------------------------------------- /tests/modules/auth/conftest.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import datetime 3 | import pytest 4 | 5 | 6 | @pytest.yield_fixture() 7 | def regular_user_oauth2_client(regular_user ,db): 8 | # pylint: disable=invalid-name,unused-argument 9 | from app.modules.auth.models import OAuth2Client 10 | 11 | admin_oauth2_client_instance = OAuth2Client( 12 | user=regular_user, 13 | client_id='regular_user_client', 14 | client_secret='regular_user_secret', 15 | redirect_uris=[], 16 | default_scopes=[] 17 | ) 18 | 19 | with db.session.begin(): 20 | db.session.add(admin_oauth2_client_instance) 21 | 22 | yield admin_oauth2_client_instance 23 | 24 | with db.session.begin(): 25 | db.session.delete(admin_oauth2_client_instance) 26 | 27 | 28 | @pytest.yield_fixture() 29 | def regular_user_oauth2_token(regular_user_oauth2_client, db): 30 | from app.modules.auth.models import OAuth2Token 31 | 32 | regular_user_token = OAuth2Token( 33 | client=regular_user_oauth2_client, 34 | user=regular_user_oauth2_client.user, 35 | access_token='test_token', 36 | refresh_token='test_refresh_token', 37 | expires=datetime.datetime.now() + datetime.timedelta(seconds=3600), 38 | token_type=OAuth2Token.TokenTypes.Bearer, 39 | scopes=[] 40 | ) 41 | 42 | with db.session.begin(): 43 | db.session.add(regular_user_token) 44 | 45 | yield regular_user_token 46 | 47 | with db.session.begin(): 48 | db.session.delete(regular_user_token) 49 | -------------------------------------------------------------------------------- /tests/modules/auth/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/modules/auth/resources/__init__.py -------------------------------------------------------------------------------- /tests/modules/auth/resources/test_creating_oauth2client.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import pytest 3 | import six 4 | 5 | 6 | @pytest.mark.parametrize('auth_scopes,redirect_uris', ( 7 | (['auth:write'], ['http://1', 'http://2']), 8 | (['auth:write', 'auth:read'], None), 9 | )) 10 | def test_creating_oauth2_client( 11 | flask_app_client, regular_user, db, auth_scopes, redirect_uris 12 | ): 13 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 14 | response = flask_app_client.post( 15 | '/api/v1/auth/oauth2_clients/', 16 | data={ 17 | 'redirect_uris': redirect_uris, 18 | 'default_scopes': ['users:read', 'users:write', 'auth:read'], 19 | } 20 | ) 21 | 22 | assert response.status_code == 200 23 | assert response.content_type == 'application/json' 24 | assert isinstance(response.json, dict) 25 | assert set(response.json.keys()) >= { 26 | 'user_id', 27 | 'client_id', 28 | 'client_secret', 29 | 'client_type', 30 | 'default_scopes', 31 | 'redirect_uris' 32 | } 33 | assert isinstance(response.json['client_id'], six.text_type) 34 | assert isinstance(response.json['client_secret'], six.text_type) 35 | assert isinstance(response.json['default_scopes'], list) 36 | assert set(response.json['default_scopes']) == {'users:read', 'users:write', 'auth:read'} 37 | assert isinstance(response.json['redirect_uris'], list) 38 | 39 | # Cleanup 40 | from app.modules.auth.models import OAuth2Client 41 | 42 | oauth2_client_instance = OAuth2Client.query.get(response.json['client_id']) 43 | assert oauth2_client_instance.client_secret == response.json['client_secret'] 44 | 45 | with db.session.begin(): 46 | db.session.delete(oauth2_client_instance) 47 | 48 | 49 | @pytest.mark.parametrize('auth_scopes', ( 50 | [], 51 | ['auth:read'], 52 | ['auth:read', 'user:read'], 53 | ['user:read'], 54 | )) 55 | def test_creating_oauth2_client_by_unauthorized_user_must_fail( 56 | flask_app_client, regular_user, auth_scopes 57 | ): 58 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 59 | response = flask_app_client.post( 60 | '/api/v1/auth/oauth2_clients/', 61 | data={ 62 | 'default_scopes': ['users:read', 'users:write', 'invalid'], 63 | } 64 | ) 65 | 66 | assert response.status_code == 401 67 | assert response.content_type == 'application/json' 68 | assert set(response.json.keys()) >= {'status', 'message'} 69 | 70 | 71 | def test_creating_oauth2_client_must_fail_for_invalid_scopes( 72 | flask_app_client, regular_user 73 | ): 74 | with flask_app_client.login(regular_user, auth_scopes=['auth:write']): 75 | response = flask_app_client.post( 76 | '/api/v1/auth/oauth2_clients/', 77 | data={ 78 | 'redirect_uris': [], 79 | 'default_scopes': ['users:read', 'users:write', 'invalid'], 80 | } 81 | ) 82 | 83 | assert response.status_code == 422 84 | assert response.content_type == 'application/json' 85 | assert set(response.json.keys()) >= {'status', 'message'} 86 | -------------------------------------------------------------------------------- /tests/modules/auth/resources/test_general_access.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize('http_method,http_path', ( 6 | ('GET', '/api/v1/auth/oauth2_clients/'), 7 | ('POST', '/api/v1/auth/oauth2_clients/'), 8 | )) 9 | def test_unauthorized_access(http_method, http_path, flask_app_client): 10 | response = flask_app_client.open(method=http_method, path=http_path) 11 | assert response.status_code == 401 12 | -------------------------------------------------------------------------------- /tests/modules/auth/resources/test_getting_oauth2clients_info.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring 3 | import pytest 4 | 5 | 6 | @pytest.mark.parametrize('auth_scopes', ( 7 | ['auth:read'], 8 | ['auth:read', 'auth:write'], 9 | )) 10 | def test_getting_list_of_oauth2_clients_by_authorized_user( 11 | flask_app_client, regular_user, regular_user_oauth2_client, auth_scopes 12 | ): 13 | # pylint: disable=invalid-name 14 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 15 | response = flask_app_client.get( 16 | '/api/v1/auth/oauth2_clients/', 17 | query_string={'user_id': regular_user.id} 18 | ) 19 | 20 | assert response.status_code == 200 21 | assert response.content_type == 'application/json' 22 | assert isinstance(response.json, list) 23 | assert set(response.json[0].keys()) >= {'client_id'} 24 | assert response.json[0]['client_id'] == regular_user_oauth2_client.client_id 25 | 26 | 27 | @pytest.mark.parametrize('auth_scopes', ( 28 | [], 29 | ['users:read'], 30 | ['auth:write'], 31 | )) 32 | def test_getting_list_of_oauth2_clients_by_unauthorized_user_must_fail( 33 | flask_app_client, 34 | regular_user, 35 | auth_scopes 36 | ): 37 | # pylint: disable=invalid-name 38 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 39 | response = flask_app_client.get('/api/v1/auth/oauth2_clients/') 40 | 41 | assert response.status_code == 401 42 | assert response.content_type == 'application/json' 43 | assert set(response.json.keys()) >= {'status', 'message'} 44 | 45 | 46 | def test_getting_list_of_oauth2_clients_should_fail_if_no_user_id( 47 | flask_app_client, regular_user 48 | ): 49 | # pylint: disable=invalid-name 50 | with flask_app_client.login(regular_user, auth_scopes=['auth:read']): 51 | response = flask_app_client.get('/api/v1/auth/oauth2_clients/') 52 | 53 | assert response.status_code == 422 54 | assert response.content_type == 'application/json' 55 | assert set(response.json.keys()) >= {'status', 'message'} 56 | 57 | 58 | def test_getting_list_of_oauth2_clients_should_fail_if_wrong_user_id( 59 | flask_app_client, regular_user 60 | ): 61 | # pylint: disable=invalid-name 62 | with flask_app_client.login(regular_user, auth_scopes=['auth:read']): 63 | response = flask_app_client.get( 64 | '/api/v1/auth/oauth2_clients/', 65 | query_string={'user_id': 100500} 66 | ) 67 | 68 | assert response.status_code == 422 69 | assert response.content_type == 'application/json' 70 | assert set(response.json.keys()) >= {'status', 'message'} 71 | -------------------------------------------------------------------------------- /tests/modules/auth/resources/test_token.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | 3 | import pytest 4 | 5 | 6 | def test_regular_user_can_retrieve_token( 7 | flask_app_client, 8 | regular_user, 9 | regular_user_oauth2_client 10 | ): 11 | response = flask_app_client.post( 12 | '/auth/oauth2/token', 13 | content_type='application/x-www-form-urlencoded', 14 | data={ 15 | 'username': regular_user.username, 16 | 'password': 'regular_user_password', 17 | 'client_id': regular_user_oauth2_client.client_id, 18 | 'client_secret': regular_user_oauth2_client.client_secret, 19 | 'grant_type': 'password', 20 | }, 21 | ) 22 | 23 | assert response.status_code == 200 24 | assert set(response.json.keys()) >= {'access_token', 'refresh_token'} 25 | 26 | 27 | def test_regular_user_cant_retrieve_token_without_credentials( 28 | flask_app_client, 29 | regular_user, 30 | ): 31 | response = flask_app_client.post( 32 | '/auth/oauth2/token', 33 | content_type='application/x-www-form-urlencoded', 34 | data={ 35 | 'username': regular_user.username, 36 | 'password': 'regular_user_password', 37 | 'grant_type': 'password', 38 | }, 39 | ) 40 | 41 | assert response.status_code == 401 42 | 43 | 44 | def test_regular_user_cant_retrieve_token_with_invalid_credentials( 45 | flask_app_client, 46 | regular_user, 47 | ): 48 | response = flask_app_client.post( 49 | '/auth/oauth2/token', 50 | content_type='application/x-www-form-urlencoded', 51 | data={ 52 | 'username': regular_user.username, 53 | 'password': 'wrong_password', 54 | 'client_id': 'wrong_client_id', 55 | 'client_secret': 'wrong_client_secret', 56 | 'grant_type': 'password', 57 | }, 58 | ) 59 | 60 | assert response.status_code == 401 61 | 62 | 63 | def test_regular_user_cant_retrieve_token_without_any_data( 64 | flask_app_client, 65 | ): 66 | response = flask_app_client.post( 67 | '/auth/oauth2/token', 68 | content_type='application/x-www-form-urlencoded', 69 | data={}, 70 | ) 71 | 72 | assert response.status_code == 400 73 | 74 | 75 | def test_regular_user_can_refresh_token( 76 | flask_app_client, 77 | regular_user_oauth2_token, 78 | ): 79 | refresh_token_response = flask_app_client.post( 80 | '/auth/oauth2/token', 81 | content_type='application/x-www-form-urlencoded', 82 | data={ 83 | 'refresh_token': regular_user_oauth2_token.refresh_token, 84 | 'client_id': regular_user_oauth2_token.client.client_id, 85 | 'client_secret': regular_user_oauth2_token.client.client_secret, 86 | 'grant_type': 'refresh_token', 87 | }, 88 | ) 89 | 90 | assert refresh_token_response.status_code == 200 91 | assert set(refresh_token_response.json.keys()) >= {'access_token'} 92 | 93 | 94 | def test_regular_user_cant_refresh_token_with_invalid_refresh_token( 95 | flask_app_client, 96 | regular_user_oauth2_token, 97 | ): 98 | refresh_token_response = flask_app_client.post( 99 | '/auth/oauth2/token', 100 | content_type='application/x-www-form-urlencoded', 101 | data={ 102 | 'refresh_token': 'wrong_refresh_token', 103 | 'client_id': regular_user_oauth2_token.client.client_id, 104 | 'client_secret': regular_user_oauth2_token.client.client_secret, 105 | 'grant_type': 'refresh_token', 106 | }, 107 | ) 108 | 109 | assert refresh_token_response.status_code == 401 110 | 111 | 112 | def test_user_cant_refresh_token_without_any_data( 113 | flask_app_client, 114 | ): 115 | refresh_token_response = flask_app_client.post( 116 | '/auth/oauth2/token', 117 | content_type='application/x-www-form-urlencoded', 118 | data={}, 119 | ) 120 | 121 | assert refresh_token_response.status_code == 400 122 | 123 | 124 | # There is a bug in flask-oauthlib: https://github.com/lepture/flask-oauthlib/issues/233 125 | @pytest.mark.xfail 126 | def test_regular_user_can_revoke_token( 127 | flask_app_client, 128 | regular_user_oauth2_token, 129 | ): 130 | data = { 131 | 'token': regular_user_oauth2_token.refresh_token, 132 | 'client_id': regular_user_oauth2_token.client.client_id, 133 | 'client_secret': regular_user_oauth2_token.client.client_secret, 134 | } 135 | revoke_token_response = flask_app_client.post( 136 | '/auth/oauth2/revoke', 137 | content_type='application/x-www-form-urlencoded', 138 | headers={ 139 | 'Authorization': 'Basic %s' % b64encode(('%s:%s' % (regular_user_oauth2_token.client.client_id, regular_user_oauth2_token.client.client_secret)).encode('utf-8')), 140 | }, 141 | data=data, 142 | ) 143 | 144 | assert revoke_token_response.status_code == 200 145 | -------------------------------------------------------------------------------- /tests/modules/auth/test_login_manager_integration.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from mock import Mock 4 | 5 | from flask import request 6 | 7 | from app.modules import auth 8 | 9 | 10 | def test_loading_user_from_anonymous_request(flask_app): 11 | with flask_app.test_request_context('/'): 12 | assert auth.load_user_from_request(request) is None 13 | 14 | def test_loading_user_from_request_with_oauth_user_cached(flask_app): 15 | mock_user = Mock() 16 | with flask_app.test_request_context('/'): 17 | request.oauth = Mock() 18 | request.oauth.user = mock_user 19 | assert auth.load_user_from_request(request) == mock_user 20 | del request.oauth 21 | 22 | def test_loading_user_from_request_with_bearer_token(flask_app, db, regular_user): 23 | oauth2_bearer_token = auth.models.OAuth2Token( 24 | client_id=0, 25 | user=regular_user, 26 | token_type='Bearer', 27 | access_token='test_access_token', 28 | scopes=[], 29 | expires=datetime.utcnow() + timedelta(days=1), 30 | ) 31 | 32 | with db.session.begin(): 33 | db.session.add(oauth2_bearer_token) 34 | 35 | with flask_app.test_request_context( 36 | path='/', 37 | headers=( 38 | ('Authorization', 'Bearer %s' % oauth2_bearer_token.access_token), 39 | ) 40 | ): 41 | assert auth.load_user_from_request(request) == regular_user 42 | 43 | with db.session.begin(): 44 | db.session.delete(oauth2_bearer_token) 45 | -------------------------------------------------------------------------------- /tests/modules/teams/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/modules/teams/__init__.py -------------------------------------------------------------------------------- /tests/modules/teams/conftest.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | import pytest 4 | 5 | 6 | @pytest.yield_fixture() 7 | def team_for_regular_user(db, regular_user, readonly_user): 8 | from app.modules.teams.models import Team, TeamMember 9 | 10 | team = Team(title="Regular User's team") 11 | regular_user_team_member = TeamMember(team=team, user=regular_user, is_leader=True) 12 | readonly_user_team_member = TeamMember(team=team, user=readonly_user) 13 | with db.session.begin(): 14 | db.session.add(team) 15 | db.session.add(regular_user_team_member) 16 | db.session.add(readonly_user_team_member) 17 | 18 | yield team 19 | 20 | # Cleanup 21 | with db.session.begin(): 22 | db.session.delete(readonly_user_team_member) 23 | db.session.delete(regular_user_team_member) 24 | db.session.delete(team) 25 | 26 | 27 | @pytest.yield_fixture() 28 | def team_for_nobody(db): 29 | """ 30 | Create a team that not belongs to regural user 31 | """ 32 | from app.modules.teams.models import Team 33 | 34 | team = Team(title="Admin User's team") 35 | with db.session.begin(): 36 | db.session.add(team) 37 | 38 | yield team 39 | 40 | # Cleanup 41 | with db.session.begin(): 42 | db.session.delete(team) 43 | -------------------------------------------------------------------------------- /tests/modules/teams/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/modules/teams/resources/__init__.py -------------------------------------------------------------------------------- /tests/modules/teams/resources/test_general_access.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize('http_method,http_path', ( 6 | ('GET', '/api/v1/teams/'), 7 | ('POST', '/api/v1/teams/'), 8 | ('GET', '/api/v1/teams/1'), 9 | ('PATCH', '/api/v1/teams/1'), 10 | ('DELETE', '/api/v1/teams/1'), 11 | ('GET', '/api/v1/teams/1/members/'), 12 | ('POST', '/api/v1/teams/1/members/'), 13 | ('DELETE', '/api/v1/teams/1/members/1'), 14 | )) 15 | def test_unauthorized_access(http_method, http_path, flask_app_client): 16 | response = flask_app_client.open(method=http_method, path=http_path) 17 | assert response.status_code == 401 18 | -------------------------------------------------------------------------------- /tests/modules/teams/resources/test_getting_teams_info.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize('auth_scopes', ( 6 | None, 7 | ('teams:write', ), 8 | )) 9 | def test_getting_list_of_teams_by_unauthorized_user_must_fail( 10 | flask_app_client, 11 | regular_user, 12 | auth_scopes 13 | ): 14 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 15 | response = flask_app_client.get('/api/v1/teams/') 16 | 17 | assert response.status_code == 401 18 | assert response.content_type == 'application/json' 19 | assert set(response.json.keys()) >= {'status', 'message'} 20 | 21 | 22 | @pytest.mark.parametrize('auth_scopes', ( 23 | ('teams:read', ), 24 | ('teams:read', 'teams:write', ), 25 | )) 26 | def test_getting_list_of_teams_by_authorized_user( 27 | flask_app_client, 28 | regular_user, 29 | team_for_regular_user, 30 | auth_scopes 31 | ): 32 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 33 | response = flask_app_client.get('/api/v1/teams/') 34 | 35 | assert response.status_code == 200 36 | assert response.content_type == 'application/json' 37 | assert isinstance(response.json, list) 38 | assert set(response.json[0].keys()) >= {'id', 'title'} 39 | if response.json[0]['id'] == team_for_regular_user.id: 40 | assert response.json[0]['title'] == team_for_regular_user.title 41 | 42 | 43 | @pytest.mark.parametrize('auth_scopes', ( 44 | None, 45 | ('teams:write', ), 46 | )) 47 | def test_getting_team_info_by_unauthorized_user_must_fail( 48 | flask_app_client, 49 | regular_user, 50 | team_for_regular_user, 51 | auth_scopes 52 | ): 53 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 54 | response = flask_app_client.get('/api/v1/teams/%d' % team_for_regular_user.id) 55 | 56 | assert response.status_code == 401 57 | assert response.content_type == 'application/json' 58 | assert set(response.json.keys()) >= {'status', 'message'} 59 | 60 | 61 | @pytest.mark.parametrize('auth_scopes', ( 62 | ('teams:read', ), 63 | ('teams:read', 'teams:write', ), 64 | )) 65 | def test_getting_team_info_by_authorized_user( 66 | flask_app_client, 67 | regular_user, 68 | team_for_regular_user, 69 | auth_scopes 70 | ): 71 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 72 | response = flask_app_client.get('/api/v1/teams/%d' % team_for_regular_user.id) 73 | 74 | assert response.status_code == 200 75 | assert response.content_type == 'application/json' 76 | assert set(response.json.keys()) >= {'id', 'title'} 77 | assert response.json['id'] == team_for_regular_user.id 78 | assert response.json['title'] == team_for_regular_user.title 79 | 80 | 81 | @pytest.mark.parametrize('auth_scopes', ( 82 | None, 83 | ('teams:write', ), 84 | )) 85 | def test_getting_list_of_team_members_by_unauthorized_user_must_fail( 86 | flask_app_client, 87 | regular_user, 88 | team_for_regular_user, 89 | auth_scopes 90 | ): 91 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 92 | response = flask_app_client.get('/api/v1/teams/%d/members/' % team_for_regular_user.id) 93 | 94 | assert response.status_code == 401 95 | assert response.content_type == 'application/json' 96 | assert set(response.json.keys()) >= {'status', 'message'} 97 | 98 | 99 | @pytest.mark.parametrize('auth_scopes', ( 100 | ('teams:read', ), 101 | ('teams:read', 'teams:write', ), 102 | )) 103 | def test_getting_list_of_team_members_by_authorized_user( 104 | flask_app_client, 105 | regular_user, 106 | team_for_regular_user, 107 | auth_scopes 108 | ): 109 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 110 | response = flask_app_client.get('/api/v1/teams/%d/members/' % team_for_regular_user.id) 111 | 112 | assert response.status_code == 200 113 | assert response.content_type == 'application/json' 114 | assert isinstance(response.json, list) 115 | assert set(response.json[0].keys()) >= {'team', 'user', 'is_leader'} 116 | assert set(member['team']['id'] for member in response.json) == {team_for_regular_user.id} 117 | assert regular_user.id in set(member['user']['id'] for member in response.json) 118 | -------------------------------------------------------------------------------- /tests/modules/teams/resources/test_options.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | import pytest 3 | 4 | @pytest.mark.parametrize('path,status_code', ( 5 | ('/api/v1/teams/', 401), 6 | ('/api/v1/users/1', 401), 7 | )) 8 | def test_teams_options_unauthorized(path, status_code, flask_app_client): 9 | response = flask_app_client.options(path) 10 | 11 | assert response.status_code == status_code 12 | 13 | 14 | @pytest.mark.parametrize('path,expected_allowed_methods', ( 15 | ('/api/v1/teams/', {'GET', 'POST', 'OPTIONS'}), 16 | ('/api/v1/teams/1', {'GET', 'OPTIONS', 'PATCH', 'DELETE'}), 17 | ('/api/v1/teams/2', {'OPTIONS'}), 18 | )) 19 | def test_teams_options_authorized( 20 | path, 21 | expected_allowed_methods, 22 | flask_app_client, 23 | regular_user, 24 | team_for_regular_user, 25 | team_for_nobody 26 | ): 27 | with flask_app_client.login(regular_user, auth_scopes=('teams:write', 'teams:read')): 28 | response = flask_app_client.options(path) 29 | 30 | assert response.status_code == 204 31 | assert set(response.headers['Allow'].split(', ')) == expected_allowed_methods 32 | 33 | 34 | @pytest.mark.parametrize('http_path,expected_allowed_methods', ( 35 | ('/api/v1/teams/', {'GET', 'POST', 'OPTIONS'}), 36 | )) 37 | def test_preflight_options_request(http_path, expected_allowed_methods, flask_app_client): 38 | response = flask_app_client.open( 39 | method='OPTIONS', 40 | path=http_path, 41 | headers={'Access-Control-Request-Method': 'post'} 42 | ) 43 | assert response.status_code == 200 44 | assert set( 45 | response.headers['Access-Control-Allow-Methods'].split(', ') 46 | ) == expected_allowed_methods 47 | -------------------------------------------------------------------------------- /tests/modules/teams/test_models.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring,invalid-name 3 | 4 | from app.modules.teams import models 5 | 6 | 7 | def test_TeamMember_check_owner(readonly_user, regular_user, team_for_regular_user): 8 | regular_user_team_member = models.TeamMember.query.filter( 9 | models.TeamMember.team == team_for_regular_user, 10 | models.TeamMember.user == readonly_user 11 | ).first() 12 | assert regular_user_team_member.check_owner(readonly_user) 13 | assert not regular_user_team_member.check_owner(None) 14 | assert not regular_user_team_member.check_owner(regular_user) 15 | 16 | def test_TeamMember_check_supervisor(readonly_user, regular_user, team_for_regular_user): 17 | regular_user_team_member = models.TeamMember.query.filter( 18 | models.TeamMember.team == team_for_regular_user, 19 | models.TeamMember.user == regular_user 20 | ).first() 21 | assert regular_user_team_member.check_supervisor(regular_user) 22 | assert not regular_user_team_member.check_supervisor(None) 23 | assert not regular_user_team_member.check_supervisor(readonly_user) 24 | 25 | def test_Team_check_owner(readonly_user, regular_user, team_for_regular_user): 26 | assert team_for_regular_user.check_owner(regular_user) 27 | assert not team_for_regular_user.check_owner(None) 28 | assert not team_for_regular_user.check_owner(readonly_user) 29 | -------------------------------------------------------------------------------- /tests/modules/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/modules/users/__init__.py -------------------------------------------------------------------------------- /tests/modules/users/conftest.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring,redefined-outer-name 3 | import pytest 4 | 5 | from flask_login import current_user, login_user, logout_user 6 | 7 | from tests import utils 8 | 9 | from app.modules.users import models 10 | 11 | 12 | @pytest.yield_fixture() 13 | def patch_User_password_scheme(): 14 | # pylint: disable=invalid-name,protected-access 15 | """ 16 | By default, the application uses ``bcrypt`` to store passwords securely. 17 | However, ``bcrypt`` is a slow hashing algorithm (by design), so it is 18 | better to downgrade it to ``plaintext`` while testing, since it will save 19 | us quite some time. 20 | """ 21 | # NOTE: It seems a hacky way, but monkeypatching is a hack anyway. 22 | password_field_context_config = models.User.password.property.columns[0].type.context._config 23 | password_field_context_config._init_scheme_list(('plaintext', )) 24 | password_field_context_config._init_records() 25 | password_field_context_config._init_default_schemes() 26 | yield 27 | password_field_context_config._init_scheme_list(('bcrypt', )) 28 | password_field_context_config._init_records() 29 | password_field_context_config._init_default_schemes() 30 | 31 | @pytest.fixture() 32 | def user_instance(patch_User_password_scheme): 33 | # pylint: disable=unused-argument,invalid-name 34 | user_id = 1 35 | _user_instance = utils.generate_user_instance(user_id=user_id) 36 | _user_instance.get_id = lambda: user_id 37 | return _user_instance 38 | 39 | @pytest.yield_fixture() 40 | def authenticated_user_instance(flask_app, user_instance): 41 | with flask_app.test_request_context('/'): 42 | login_user(user_instance) 43 | yield current_user 44 | logout_user() 45 | 46 | @pytest.yield_fixture() 47 | def anonymous_user_instance(flask_app): 48 | with flask_app.test_request_context('/'): 49 | yield current_user 50 | -------------------------------------------------------------------------------- /tests/modules/users/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobeverist/flask-restplus-mongo/c531bebbc2a33c83b44fa1434f3db79d9faa62fa/tests/modules/users/resources/__init__.py -------------------------------------------------------------------------------- /tests/modules/users/resources/test_general_access.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | import pytest 3 | 4 | 5 | @pytest.mark.parametrize('http_method,http_path', ( 6 | ('GET', '/api/v1/users/'), 7 | ('GET', '/api/v1/users/1'), 8 | ('PATCH', '/api/v1/users/1'), 9 | ('GET', '/api/v1/users/me'), 10 | )) 11 | def test_unauthorized_access(http_method, http_path, flask_app_client): 12 | response = flask_app_client.open(method=http_method, path=http_path) 13 | assert response.status_code == 401 14 | -------------------------------------------------------------------------------- /tests/modules/users/resources/test_getting_users_info.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring 3 | import pytest 4 | 5 | 6 | @pytest.mark.parametrize('auth_scopes', ( 7 | ('users:write', ), 8 | ('users:read', ), 9 | ('users:read', 'users:write', ), 10 | )) 11 | def test_getting_list_of_users_by_unauthorized_user_must_fail( 12 | flask_app_client, 13 | regular_user, 14 | auth_scopes 15 | ): 16 | # pylint: disable=invalid-name 17 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 18 | response = flask_app_client.get('/api/v1/users/') 19 | 20 | if 'users:read' in auth_scopes: 21 | assert response.status_code == 403 22 | else: 23 | assert response.status_code == 401 24 | assert response.content_type == 'application/json' 25 | assert set(response.json.keys()) >= {'status', 'message'} 26 | 27 | @pytest.mark.parametrize('auth_scopes', ( 28 | ('users:read', ), 29 | ('users:read', 'users:write', ), 30 | )) 31 | def test_getting_list_of_users_by_authorized_user(flask_app_client, admin_user, auth_scopes): 32 | # pylint: disable=invalid-name 33 | with flask_app_client.login(admin_user, auth_scopes=auth_scopes): 34 | response = flask_app_client.get('/api/v1/users/') 35 | 36 | assert response.status_code == 200 37 | assert response.content_type == 'application/json' 38 | assert isinstance(response.json, list) 39 | assert set(response.json[0].keys()) >= {'id', 'username'} 40 | 41 | def test_getting_user_info_by_unauthorized_user(flask_app_client, regular_user, admin_user): 42 | # pylint: disable=invalid-name 43 | with flask_app_client.login(regular_user, auth_scopes=('users:read',)): 44 | response = flask_app_client.get('/api/v1/users/%d' % admin_user.id) 45 | 46 | assert response.status_code == 403 47 | assert response.content_type == 'application/json' 48 | assert isinstance(response.json, dict) 49 | assert set(response.json.keys()) >= {'status', 'message'} 50 | 51 | def test_getting_user_info_by_authorized_user(flask_app_client, regular_user, admin_user): 52 | # pylint: disable=invalid-name 53 | with flask_app_client.login(admin_user, auth_scopes=('users:read',)): 54 | response = flask_app_client.get('/api/v1/users/%d' % regular_user.id) 55 | 56 | assert response.status_code == 200 57 | assert response.content_type == 'application/json' 58 | assert isinstance(response.json, dict) 59 | assert set(response.json.keys()) >= {'id', 'username'} 60 | assert 'password' not in response.json.keys() 61 | 62 | def test_getting_user_info_by_owner(flask_app_client, regular_user): 63 | # pylint: disable=invalid-name 64 | with flask_app_client.login(regular_user, auth_scopes=('users:read',)): 65 | response = flask_app_client.get('/api/v1/users/%d' % regular_user.id) 66 | 67 | assert response.status_code == 200 68 | assert response.content_type == 'application/json' 69 | assert isinstance(response.json, dict) 70 | assert set(response.json.keys()) >= {'id', 'username'} 71 | assert 'password' not in response.json.keys() 72 | 73 | def test_getting_user_me_info(flask_app_client, regular_user): 74 | # pylint: disable=invalid-name 75 | with flask_app_client.login(regular_user, auth_scopes=('users:read',)): 76 | response = flask_app_client.get('/api/v1/users/me') 77 | 78 | assert response.status_code == 200 79 | assert response.content_type == 'application/json' 80 | assert isinstance(response.json, dict) 81 | assert set(response.json.keys()) >= {'id', 'username'} 82 | assert 'password' not in response.json.keys() 83 | -------------------------------------------------------------------------------- /tests/modules/users/resources/test_options.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | import pytest 3 | 4 | @pytest.mark.parametrize('path,status_code,expected_allowed_methods', ( 5 | ('/api/v1/users/', 204, {'POST', 'OPTIONS'}), 6 | ('/api/v1/users/1', 401, None), 7 | )) 8 | def test_users_options_unauthorized(path, status_code, expected_allowed_methods, flask_app_client): 9 | response = flask_app_client.options(path) 10 | 11 | assert response.status_code == status_code 12 | if expected_allowed_methods: 13 | assert set(response.headers['Allow'].split(', ')) == expected_allowed_methods 14 | 15 | 16 | @pytest.mark.parametrize('path,expected_allowed_methods', ( 17 | ('/api/v1/users/', {'POST', 'OPTIONS'}), 18 | ('/api/v1/users/1', {'GET', 'OPTIONS', 'PATCH'}), 19 | ('/api/v1/users/2', {'OPTIONS'}), 20 | )) 21 | def test_users_options_authorized(path, expected_allowed_methods, flask_app_client, regular_user): 22 | with flask_app_client.login(regular_user, auth_scopes=('users:write', 'users:read')): 23 | response = flask_app_client.options(path) 24 | 25 | assert response.status_code == 204 26 | assert set(response.headers['Allow'].split(', ')) == expected_allowed_methods 27 | -------------------------------------------------------------------------------- /tests/modules/users/resources/test_signup.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring 3 | 4 | 5 | def test_signup_form(flask_app_client): 6 | response = flask_app_client.get('/api/v1/users/signup_form') 7 | assert response.status_code == 200 8 | assert response.content_type == 'application/json' 9 | assert set(response.json.keys()) == {"recaptcha_server_key"} 10 | 11 | def create_new_user(flask_app_client, data, must_succeed=True): 12 | """ 13 | Helper function for valid new user creation. 14 | """ 15 | _data = { 16 | 'recaptcha_key': "secret_key", 17 | } 18 | _data.update(data) 19 | response = flask_app_client.post('/api/v1/users/', data=_data) 20 | 21 | if must_succeed: 22 | assert response.status_code == 200 23 | assert response.content_type == 'application/json' 24 | assert set(response.json.keys()) >= {'id', 'username'} 25 | return response.json['id'] 26 | return response 27 | 28 | def test_new_user_creation(patch_User_password_scheme, flask_app_client, db): 29 | # pylint: disable=invalid-name,unused-argument 30 | user_id = create_new_user( 31 | flask_app_client, 32 | data={ 33 | 'username': "user1", 34 | 'email': "user1@email.com", 35 | 'password': "user1_password", 36 | } 37 | ) 38 | assert isinstance(user_id, int) 39 | 40 | # Cleanup 41 | from app.modules.users.models import User 42 | 43 | user1_instance = User.query.get(user_id) 44 | assert user1_instance.username == "user1" 45 | assert user1_instance.email == "user1@email.com" 46 | assert user1_instance.password == "user1_password" 47 | 48 | with db.session.begin(): 49 | db.session.delete(user1_instance) 50 | 51 | def test_new_user_creation_without_captcha_must_fail(flask_app_client): 52 | # pylint: disable=invalid-name 53 | response = create_new_user( 54 | flask_app_client, 55 | data={ 56 | 'recaptcha_key': None, 57 | 'username': "user1", 58 | 'email': "user1@email.com", 59 | 'password': "user1_password", 60 | }, 61 | must_succeed=False 62 | ) 63 | assert response.status_code == 403 64 | assert response.content_type == 'application/json' 65 | assert set(response.json.keys()) >= {'status', 'message'} 66 | 67 | def test_new_user_creation_with_incorrect_captcha_must_fail(flask_app_client): 68 | # pylint: disable=invalid-name 69 | response = create_new_user( 70 | flask_app_client, 71 | data={ 72 | 'recaptcha_key': 'invalid_captcha_key', 73 | 'username': "user1", 74 | 'email': "user1@email.com", 75 | 'password': "user1_password", 76 | }, 77 | must_succeed=False 78 | ) 79 | assert response.status_code == 403 80 | assert response.content_type == 'application/json' 81 | assert set(response.json.keys()) >= {'status', 'message'} 82 | 83 | def test_new_user_creation_without_captcha_but_admin_user( 84 | patch_User_password_scheme, 85 | flask_app_client, 86 | admin_user, 87 | db 88 | ): 89 | # pylint: disable=invalid-name,unused-argument 90 | with flask_app_client.login(admin_user): 91 | user_id = create_new_user( 92 | flask_app_client, 93 | data={ 94 | 'recaptcha_key': None, 95 | 'username': "user1", 96 | 'email': "user1@email.com", 97 | 'password': "user1_password", 98 | } 99 | ) 100 | assert isinstance(user_id, int) 101 | 102 | # Cleanup 103 | from app.modules.users.models import User 104 | 105 | user1_instance = User.query.get(user_id) 106 | assert user1_instance.username == "user1" 107 | assert user1_instance.email == "user1@email.com" 108 | assert user1_instance.password == "user1_password" 109 | 110 | with db.session.begin(): 111 | db.session.delete(user1_instance) 112 | 113 | def test_new_user_creation_duplicate_must_fail(flask_app_client, db): 114 | # pylint: disable=invalid-name 115 | user_id = create_new_user( 116 | flask_app_client, 117 | data={ 118 | 'username': "user1", 119 | 'email': "user1@email.com", 120 | 'password': "user1_password", 121 | } 122 | ) 123 | response = create_new_user( 124 | flask_app_client, 125 | data={ 126 | 'username': "user1", 127 | 'email': "user1@email.com", 128 | 'password': "user1_password", 129 | }, 130 | must_succeed=False 131 | ) 132 | assert response.status_code == 409 133 | assert response.content_type == 'application/json' 134 | assert set(response.json.keys()) >= {'status', 'message'} 135 | 136 | # Cleanup 137 | from app.modules.users.models import User 138 | 139 | user1_instance = User.query.get(user_id) 140 | with db.session.begin(): 141 | db.session.delete(user1_instance) 142 | -------------------------------------------------------------------------------- /tests/modules/users/test_models.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=invalid-name,missing-docstring 3 | 4 | import pytest 5 | 6 | from app.modules.users import models 7 | 8 | 9 | def test_User_repr(user_instance): 10 | assert len(str(user_instance)) > 0 11 | 12 | 13 | def test_User_auth(user_instance): 14 | assert user_instance.is_authenticated 15 | assert not user_instance.is_anonymous 16 | 17 | 18 | @pytest.mark.parametrize( 19 | 'init_static_roles,is_internal,is_admin,is_regular_user,is_active', 20 | [ 21 | (_init_static_roles, _is_internal, _is_admin, _is_regular_user, _is_active) \ 22 | for _init_static_roles in ( 23 | 0, 24 | (models.User.StaticRoles.INTERNAL.mask 25 | | models.User.StaticRoles.ADMIN.mask 26 | | models.User.StaticRoles.REGULAR_USER.mask 27 | | models.User.StaticRoles.ACTIVE.mask 28 | ) 29 | ) \ 30 | for _is_internal in (False, True) \ 31 | for _is_admin in (False, True) \ 32 | for _is_regular_user in (False, True) \ 33 | for _is_active in (False, True) 34 | ] 35 | ) 36 | def test_User_static_roles_setting( 37 | init_static_roles, 38 | is_internal, 39 | is_admin, 40 | is_regular_user, 41 | is_active, 42 | user_instance 43 | ): 44 | """ 45 | Static User Roles are saved as bit flags into one ``static_roles`` 46 | integer field. Ideally, it would be better implemented as a custom field, 47 | and the plugin would be tested separately, but for now this implementation 48 | is fine, so we test it as it is. 49 | """ 50 | user_instance.static_roles = init_static_roles 51 | 52 | if is_internal: 53 | user_instance.set_static_role(user_instance.StaticRoles.INTERNAL) 54 | else: 55 | user_instance.unset_static_role(user_instance.StaticRoles.INTERNAL) 56 | 57 | if is_admin: 58 | user_instance.set_static_role(user_instance.StaticRoles.ADMIN) 59 | else: 60 | user_instance.unset_static_role(user_instance.StaticRoles.ADMIN) 61 | 62 | if is_regular_user: 63 | user_instance.set_static_role(user_instance.StaticRoles.REGULAR_USER) 64 | else: 65 | user_instance.unset_static_role(user_instance.StaticRoles.REGULAR_USER) 66 | 67 | if is_active: 68 | user_instance.set_static_role(user_instance.StaticRoles.ACTIVE) 69 | else: 70 | user_instance.unset_static_role(user_instance.StaticRoles.ACTIVE) 71 | 72 | assert user_instance.has_static_role(user_instance.StaticRoles.INTERNAL) is is_internal 73 | assert user_instance.has_static_role(user_instance.StaticRoles.ADMIN) is is_admin 74 | assert user_instance.has_static_role(user_instance.StaticRoles.REGULAR_USER) is is_regular_user 75 | assert user_instance.has_static_role(user_instance.StaticRoles.ACTIVE) is is_active 76 | assert user_instance.is_internal is is_internal 77 | assert user_instance.is_admin is is_admin 78 | assert user_instance.is_regular_user is is_regular_user 79 | assert user_instance.is_active is is_active 80 | 81 | if not is_active and not is_regular_user and not is_admin and not is_internal: 82 | assert user_instance.static_roles == 0 83 | 84 | 85 | def test_User_check_owner(user_instance): 86 | assert user_instance.check_owner(user_instance) 87 | assert not user_instance.check_owner(models.User()) 88 | 89 | 90 | def test_User_find_with_password(patch_User_password_scheme, db): # pylint: disable=unused-argument 91 | 92 | def create_user(username, password): 93 | user = models.User( 94 | username=username, 95 | password=password, 96 | first_name="any", 97 | middle_name="any", 98 | last_name="any", 99 | email="%s@email.com" % username, 100 | ) 101 | return user 102 | 103 | user1 = create_user("user1", "user1password") 104 | user2 = create_user("user2", "user2password") 105 | with db.session.begin(): 106 | db.session.add(user1) 107 | db.session.add(user2) 108 | 109 | assert models.User.find_with_password("user1", "user1password") == user1 110 | assert models.User.find_with_password("user1", "wrong-user1password") is None 111 | assert models.User.find_with_password("user2", "user1password") is None 112 | assert models.User.find_with_password("user2", "user2password") == user2 113 | assert models.User.find_with_password("nouser", "userpassword") is None 114 | 115 | with db.session.begin(): 116 | db.session.delete(user1) 117 | db.session.delete(user2) 118 | -------------------------------------------------------------------------------- /tests/modules/users/test_schemas.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=invalid-name,missing-docstring 3 | 4 | from app.modules.users import schemas 5 | 6 | 7 | def test_BaseUserSchema_dump_empty_input(): 8 | dumped_result = schemas.BaseUserSchema().dump({}) 9 | assert dumped_result.errors == {} 10 | assert dumped_result.data == {} 11 | 12 | def test_BaseUserSchema_dump_user_instance(user_instance): 13 | user_instance.password = "password" 14 | dumped_result = schemas.BaseUserSchema().dump(user_instance) 15 | assert dumped_result.errors == {} 16 | assert 'password' not in dumped_result.data 17 | assert set(dumped_result.data.keys()) == { 18 | 'id', 19 | 'username', 20 | 'first_name', 21 | 'middle_name', 22 | 'last_name' 23 | } 24 | 25 | def test_DetailedUserSchema_dump_user_instance(user_instance): 26 | user_instance.password = "password" 27 | dumped_result = schemas.DetailedUserSchema().dump(user_instance) 28 | assert dumped_result.errors == {} 29 | assert 'password' not in dumped_result.data 30 | assert set(dumped_result.data.keys()) == { 31 | 'id', 32 | 'username', 33 | 'first_name', 34 | 'middle_name', 35 | 'last_name', 36 | 'email', 37 | 'created', 38 | 'updated', 39 | 'is_active', 40 | 'is_regular_user', 41 | 'is_admin', 42 | } 43 | 44 | def test_UserSignupFormSchema_dump(): 45 | form_data = {'recaptcha_server_key': 'key'} 46 | dumped_result = schemas.UserSignupFormSchema().dump(form_data) 47 | assert dumped_result.errors == {} 48 | assert dumped_result.data == form_data 49 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | mock 3 | swagger_spec_validator 4 | 5 | ## Optional packages 6 | # pytest-xdist 7 | # pytest-cov 8 | # coverage 9 | -------------------------------------------------------------------------------- /tests/test_app_creation.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring 3 | import pytest 4 | 5 | from app import CONFIG_NAME_MAPPER, create_app 6 | 7 | 8 | def test_create_app(): 9 | try: 10 | create_app() 11 | except SystemExit: 12 | # Clean git repository doesn't have `local_config.py`, so it is fine 13 | # if we get SystemExit error. 14 | pass 15 | 16 | @pytest.mark.parametrize('flask_config_name', ['production', 'development', 'testing']) 17 | def test_create_app_passing_flask_config_name(flask_config_name): 18 | create_app(flask_config_name=flask_config_name) 19 | 20 | @pytest.mark.parametrize('flask_config_name', ['production', 'development', 'testing']) 21 | def test_create_app_passing_FLASK_CONFIG_env(monkeypatch, flask_config_name): 22 | monkeypatch.setenv('FLASK_CONFIG', flask_config_name) 23 | create_app() 24 | 25 | def test_create_app_with_conflicting_config(monkeypatch): 26 | monkeypatch.setenv('FLASK_CONFIG', 'production') 27 | with pytest.raises(AssertionError): 28 | create_app('development') 29 | 30 | def test_create_app_with_non_existing_config(): 31 | with pytest.raises(KeyError): 32 | create_app('non-existing-config') 33 | 34 | def test_create_app_with_broken_import_config(): 35 | CONFIG_NAME_MAPPER['broken-import-config'] = 'broken-import-config' 36 | with pytest.raises(ImportError): 37 | create_app('broken-import-config') 38 | del CONFIG_NAME_MAPPER['broken-import-config'] 39 | -------------------------------------------------------------------------------- /tests/test_openapi_spec_validity.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=missing-docstring 3 | import json 4 | 5 | from jsonschema import RefResolver 6 | from swagger_spec_validator import validator20 7 | 8 | 9 | def test_openapi_spec_validity(flask_app_client): 10 | raw_openapi_spec = flask_app_client.get('/api/v1/swagger.json').data 11 | deserialized_openapi_spec = json.loads(raw_openapi_spec.decode('utf-8')) 12 | assert isinstance(validator20.validate_spec(deserialized_openapi_spec), RefResolver) 13 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Testing utils 3 | ------------- 4 | """ 5 | 6 | from contextlib import contextmanager 7 | from datetime import datetime, timedelta 8 | import json 9 | 10 | from flask import Response 11 | from flask.testing import FlaskClient 12 | from werkzeug.utils import cached_property 13 | 14 | 15 | class AutoAuthFlaskClient(FlaskClient): 16 | """ 17 | A helper FlaskClient class with a useful for testing ``login`` context 18 | manager. 19 | """ 20 | 21 | def __init__(self, *args, **kwargs): 22 | super(AutoAuthFlaskClient, self).__init__(*args, **kwargs) 23 | self._user = None 24 | self._auth_scopes = None 25 | 26 | @contextmanager 27 | def login(self, user, auth_scopes=None): 28 | """ 29 | Example: 30 | >>> with flask_app_client.login(user, auth_scopes=['users:read']): 31 | ... flask_app_client.get('/api/v1/users/') 32 | """ 33 | self._user = user 34 | self._auth_scopes = auth_scopes or [] 35 | yield self 36 | self._user = None 37 | self._auth_scopes = None 38 | 39 | def open(self, *args, **kwargs): 40 | if self._user is not None: 41 | from app.extensions import db 42 | from app.modules.auth.models import OAuth2Token 43 | 44 | oauth2_bearer_token = OAuth2Token( 45 | client_id=0, 46 | user=self._user, 47 | token_type='Bearer', 48 | access_token='test_access_token', 49 | scopes=self._auth_scopes, 50 | expires=datetime.utcnow() + timedelta(days=1), 51 | ) 52 | 53 | with db.session.begin(): 54 | db.session.add(oauth2_bearer_token) 55 | 56 | extra_headers = ( 57 | ( 58 | 'Authorization', 59 | '{token.token_type} {token.access_token}'.format(token=oauth2_bearer_token) 60 | ), 61 | ) 62 | if kwargs.get('headers'): 63 | kwargs['headers'] += extra_headers 64 | else: 65 | kwargs['headers'] = extra_headers 66 | 67 | response = super(AutoAuthFlaskClient, self).open(*args, **kwargs) 68 | 69 | if self._user is not None: 70 | with db.session.begin(): 71 | db.session.delete(oauth2_bearer_token) 72 | 73 | return response 74 | 75 | 76 | class JSONResponse(Response): 77 | # pylint: disable=too-many-ancestors 78 | """ 79 | A Response class with extra useful helpers, i.e. ``.json`` property. 80 | """ 81 | 82 | @cached_property 83 | def json(self): 84 | return json.loads(self.get_data(as_text=True)) 85 | 86 | 87 | def generate_user_instance( 88 | user_id=None, 89 | username="username", 90 | password=None, 91 | email=None, 92 | first_name="First Name", 93 | middle_name="Middle Name", 94 | last_name="Last Name", 95 | created=None, 96 | updated=None, 97 | is_active=True, 98 | is_regular_user=True, 99 | is_admin=False, 100 | is_internal=False 101 | ): 102 | """ 103 | Returns: 104 | user_instance (User) - an not committed to DB instance of a User model. 105 | """ 106 | # pylint: disable=too-many-arguments 107 | from app.modules.users.models import User 108 | if password is None: 109 | password = '%s_password' % username 110 | user_instance = User( 111 | id=user_id, 112 | username=username, 113 | first_name=first_name, 114 | middle_name=middle_name, 115 | last_name=last_name, 116 | password=password, 117 | email=email or '%s@email.com' % username, 118 | created=created or datetime.now(), 119 | updated=updated or datetime.now(), 120 | is_active=is_active, 121 | is_regular_user=is_regular_user, 122 | is_admin=is_admin, 123 | is_internal=is_internal 124 | ) 125 | user_instance.password_secret = password 126 | return user_instance 127 | --------------------------------------------------------------------------------