├── .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 │ ├── flask_sqlalchemy │ │ └── __init__.py │ └── logging │ │ └── __init__.py ├── modules │ ├── __init__.py │ ├── api │ │ └── __init__.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-css.html │ ├── swagger-ui-libs.html │ └── swagger-ui.html ├── clients ├── javascript │ ├── .npmignore │ ├── Dockerfile │ └── swagger_codegen_config.json └── python │ └── swagger_codegen_config.json ├── config.py ├── conftest.py ├── deploy ├── README.md ├── stack1 │ ├── README.md │ ├── docker-compose.yml │ └── revproxy │ │ ├── Dockerfile │ │ ├── conf.d │ │ └── default.conf │ │ └── index.html └── stack2 │ ├── 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 ├── 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 │ ├── boilerplates.py │ ├── boilerplates_templates │ │ └── crud_module │ │ │ ├── __init__.py.template │ │ │ ├── models.py.template │ │ │ ├── parameters.py.template │ │ │ ├── resources.py.template │ │ │ └── schemas.py.template │ ├── 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 | Procfile 4 | 5 | example.db 6 | 7 | .git* 8 | 9 | deploy 10 | tests 11 | clients 12 | 13 | __pycache__ 14 | */__pycache__ 15 | */*/__pycache__ 16 | */*/*/__pycache__ 17 | */*/*/*/__pycache__ 18 | */*/*/*/*/__pycache__ 19 | 20 | *.pyc 21 | */*.pyc 22 | */*/*.pyc 23 | */*/*/*.pyc 24 | */*/*/*/*.pyc 25 | */*/*/*/*/*.pyc 26 | -------------------------------------------------------------------------------- /.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 | clients/*/swagger.json 55 | clients/*/dist 56 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.5" 5 | - "3.6" 6 | - "nightly" 7 | - "pypy" 8 | 9 | matrix: 10 | allow_failures: 11 | - python: "nightly" 12 | - python: "pypy" 13 | 14 | branches: 15 | only: 16 | - master 17 | 18 | install: 19 | # Travis has pypy 2.5.0, which is way too old, so we upgrade it on the fly: 20 | - | 21 | if [ "$TRAVIS_PYTHON_VERSION" = "pypy" ]; then 22 | export PYENV_ROOT="$HOME/.pyenv" 23 | if [ -f "$PYENV_ROOT/bin/pyenv" ]; then 24 | pushd "$PYENV_ROOT" && git pull && popd 25 | else 26 | rm -rf "$PYENV_ROOT" && git clone --depth 1 https://github.com/yyuu/pyenv.git "$PYENV_ROOT" 27 | fi 28 | export PYPY_VERSION="5.6.0" 29 | "$PYENV_ROOT/bin/pyenv" install --skip-existing "pypy-$PYPY_VERSION" 30 | virtualenv --python="$PYENV_ROOT/versions/pypy-$PYPY_VERSION/bin/python" "$HOME/virtualenvs/pypy-$PYPY_VERSION" 31 | source "$HOME/virtualenvs/pypy-$PYPY_VERSION/bin/activate" 32 | fi 33 | - travis_retry pip install pylint 34 | - travis_retry pip install -r app/requirements.txt 35 | - travis_retry pip install -r tests/requirements.txt 36 | - travis_retry pip install pytest-cov coverage coveralls codacy-coverage 37 | 38 | cache: 39 | directories: 40 | - $HOME/.cache/pip 41 | - $HOME/.pyenv 42 | 43 | script: 44 | py.test --cov=app 45 | 46 | after_success: 47 | - pylint --disable=fixme app 48 | - coveralls 49 | - coverage xml && python-codacy-coverage -r coverage.xml 50 | -------------------------------------------------------------------------------- /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 | ARG INCLUDE_POSTGRESQL=false 11 | ARG INCLUDE_UWSGI=false 12 | RUN apk add --no-cache --virtual=.build_dependencies musl-dev gcc python3-dev libffi-dev linux-headers && \ 13 | cd /opt/www && \ 14 | pip install -r tasks/requirements.txt && \ 15 | invoke app.dependencies.install && \ 16 | ( \ 17 | if [ "$INCLUDE_POSTGRESQL" = 'true' ]; then \ 18 | apk add --no-cache libpq && \ 19 | apk add --no-cache --virtual=.build_dependencies postgresql-dev && \ 20 | pip install psycopg2 ; \ 21 | fi \ 22 | ) && \ 23 | ( if [ "$INCLUDE_UWSGI" = 'true' ]; then pip install uwsgi ; fi ) && \ 24 | rm -rf ~/.cache/pip && \ 25 | apk del .build_dependencies 26 | 27 | COPY "./" "./" 28 | 29 | RUN chown -R nobody "." && \ 30 | if [ ! -e "./local_config.py" ]; then \ 31 | cp "./local_config.py.template" "./local_config.py" ; \ 32 | fi 33 | 34 | USER nobody 35 | CMD [ "invoke", "app.run", "--no-install-dependencies", "--host", "0.0.0.0" ] 36 | -------------------------------------------------------------------------------- /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 os 6 | import sys 7 | 8 | from flask import Flask 9 | from werkzeug.contrib.fixers import ProxyFix 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 | # This is a workaround for Alpine Linux (musl libc) quirk: 24 | # https://github.com/docker-library/python/issues/211 25 | import threading 26 | threading.stack_size(2*1024*1024) 27 | 28 | app = Flask(__name__, **kwargs) 29 | 30 | env_flask_config_name = os.getenv('FLASK_CONFIG') 31 | if flask_config_name is None: 32 | flask_config_name = env_flask_config_name or 'local' 33 | elif env_flask_config_name: 34 | assert env_flask_config_name == flask_config_name, ( 35 | "FLASK_CONFIG environment variable (\"%s\") and flask_config_name argument " 36 | "(\"%s\") are both set and are not the same." % ( 37 | env_flask_config_name, 38 | flask_config_name 39 | ) 40 | ) 41 | 42 | try: 43 | app.config.from_object(CONFIG_NAME_MAPPER[flask_config_name]) 44 | except ImportError: 45 | if flask_config_name == 'local': 46 | app.logger.error( # pylint: disable=no-member 47 | "You have to have `local_config.py` or `local_config/__init__.py` in order to use " 48 | "the default 'local' Flask Config. Alternatively, you may set `FLASK_CONFIG` " 49 | "environment variable to one of the following options: development, production, " 50 | "testing." 51 | ) 52 | sys.exit(1) 53 | raise 54 | 55 | if app.config['REVERSE_PROXY_SETUP']: 56 | app.wsgi_app = ProxyFix(app.wsgi_app) 57 | 58 | from . import extensions 59 | extensions.init_app(app) 60 | 61 | from . import modules 62 | modules.init_app(app) 63 | 64 | return app 65 | -------------------------------------------------------------------------------- /app/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=invalid-name,wrong-import-position,wrong-import-order 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 | from .logging import Logging 13 | logging = Logging() 14 | 15 | from flask_cors import CORS 16 | cross_origin_resource_sharing = CORS() 17 | 18 | from .flask_sqlalchemy import SQLAlchemy 19 | db = SQLAlchemy() 20 | 21 | from sqlalchemy_utils import force_auto_coercion, force_instant_defaults 22 | force_auto_coercion() 23 | force_instant_defaults() 24 | 25 | from flask_login import LoginManager 26 | login_manager = LoginManager() 27 | 28 | from flask_marshmallow import Marshmallow 29 | marshmallow = Marshmallow() 30 | 31 | from .auth import OAuth2Provider 32 | oauth2 = OAuth2Provider() 33 | 34 | from . import api 35 | 36 | 37 | def init_app(app): 38 | """ 39 | Application extensions initialization. 40 | """ 41 | for extension in ( 42 | logging, 43 | cross_origin_resource_sharing, 44 | db, 45 | login_manager, 46 | marshmallow, 47 | api, 48 | oauth2, 49 | ): 50 | extension.init_app(app) 51 | -------------------------------------------------------------------------------- /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 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, path=None): 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, path=path) 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._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 | """ 29 | Custom abort function used to provide extra information in the error 30 | response, namely, ``status`` and ``message`` info. 31 | """ 32 | if message is None: 33 | if code in API_DEFAULT_HTTP_CODE_MESSAGES: # pylint: disable=consider-using-get 34 | message = API_DEFAULT_HTTP_CODE_MESSAGES[code] 35 | else: 36 | message = HTTPStatus(code).description # pylint: disable=no-value-for-parameter 37 | restplus_abort(code=code, status=code, message=message, **kwargs) 38 | -------------------------------------------------------------------------------- /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 related PR for more details: 17 | https://github.com/sloria/webargs/issues/122 18 | """ 19 | 20 | def handle_error(self, error, *args, **kwargs): 21 | # pylint: disable=arguments-differ 22 | """ 23 | Handles errors during parsing. Aborts the current HTTP request and 24 | responds with a 422 error. 25 | """ 26 | status_code = getattr(error, 'status_code', self.DEFAULT_VALIDATION_STATUS) 27 | abort(status_code, messages=error.messages) 28 | -------------------------------------------------------------------------------- /app/extensions/auth/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Auth extension 4 | ============== 5 | """ 6 | 7 | from .oauth2 import OAuth2Provider 8 | -------------------------------------------------------------------------------- /app/extensions/flask_sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Flask-SQLAlchemy adapter 4 | ------------------------ 5 | """ 6 | import sqlite3 7 | 8 | from sqlalchemy import engine, MetaData 9 | 10 | from flask_sqlalchemy import SQLAlchemy as BaseSQLAlchemy 11 | 12 | 13 | def set_sqlite_pragma(dbapi_connection, connection_record): 14 | # pylint: disable=unused-argument 15 | """ 16 | SQLite supports FOREIGN KEY syntax when emitting CREATE statements for 17 | tables, however by default these constraints have no effect on the 18 | operation of the table. 19 | 20 | http://docs.sqlalchemy.org/en/latest/dialects/sqlite.html#foreign-key-support 21 | """ 22 | if not isinstance(dbapi_connection, sqlite3.Connection): 23 | return 24 | cursor = dbapi_connection.cursor() 25 | cursor.execute("PRAGMA foreign_keys=ON") 26 | cursor.close() 27 | 28 | 29 | class AlembicDatabaseMigrationConfig(object): 30 | """ 31 | Helper config holder that provides missing functions of Flask-Alembic 32 | package since we use custom invoke tasks instead. 33 | """ 34 | 35 | def __init__(self, database, directory='migrations', **kwargs): 36 | self.db = database # pylint: disable=invalid-name 37 | self.directory = directory 38 | self.configure_args = kwargs 39 | 40 | 41 | class SQLAlchemy(BaseSQLAlchemy): 42 | """ 43 | Customized Flask-SQLAlchemy adapter with enabled autocommit, constraints 44 | auto-naming conventions and ForeignKey constraints for SQLite. 45 | """ 46 | 47 | def __init__(self, *args, **kwargs): 48 | if 'session_options' not in kwargs: 49 | kwargs['session_options'] = {} 50 | kwargs['session_options']['autocommit'] = True 51 | # Configure Constraint Naming Conventions: 52 | # http://docs.sqlalchemy.org/en/latest/core/constraints.html#constraint-naming-conventions 53 | kwargs['metadata'] = MetaData( 54 | naming_convention={ 55 | 'pk': 'pk_%(table_name)s', 56 | 'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s', 57 | 'ix': 'ix_%(table_name)s_%(column_0_name)s', 58 | 'uq': 'uq_%(table_name)s_%(column_0_name)s', 59 | 'ck': 'ck_%(table_name)s_%(constraint_name)s', 60 | } 61 | ) 62 | super(SQLAlchemy, self).__init__(*args, **kwargs) 63 | 64 | def init_app(self, app): 65 | super(SQLAlchemy, self).init_app(app) 66 | 67 | database_uri = app.config['SQLALCHEMY_DATABASE_URI'] 68 | assert database_uri, "SQLALCHEMY_DATABASE_URI must be configured!" 69 | if database_uri.startswith('sqlite:'): 70 | self.event.listens_for(engine.Engine, "connect")(set_sqlite_pragma) 71 | 72 | app.extensions['migrate'] = AlembicDatabaseMigrationConfig(self, compare_type=True) 73 | -------------------------------------------------------------------------------- /app/extensions/logging/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Logging adapter 4 | --------------- 5 | """ 6 | import logging 7 | 8 | 9 | class Logging(object): 10 | """ 11 | This is a helper extension, which adjusts logging configuration for the 12 | application. 13 | """ 14 | 15 | def __init__(self, app=None): 16 | if app: 17 | self.init_app(app) 18 | 19 | def init_app(self, app): 20 | """ 21 | Common Flask interface to initialize the logging according to the 22 | application configuration. 23 | """ 24 | # We don't need the default Flask's loggers when using our invoke tasks 25 | # since we set up beautiful colorful loggers globally. 26 | for handler in list(app.logger.handlers): 27 | app.logger.removeHandler(handler) 28 | app.logger.propagate = True 29 | 30 | if app.debug: 31 | logging.getLogger('flask_oauthlib').setLevel(logging.DEBUG) 32 | app.logger.setLevel(logging.DEBUG) 33 | 34 | # We don't need the default SQLAlchemy loggers when using our invoke 35 | # tasks since we set up beautiful colorful loggers globally. 36 | # NOTE: This particular workaround is for the SQLALCHEMY_ECHO mode, 37 | # when all SQL commands get printed (without these lines, they will get 38 | # printed twice). 39 | sqla_logger = logging.getLogger('sqlalchemy.engine.base.Engine') 40 | for hdlr in list(sqla_logger.handlers): 41 | sqla_logger.removeHandler(hdlr) 42 | sqla_logger.addHandler(logging.NullHandler()) 43 | -------------------------------------------------------------------------------- /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/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 # pylint: disable=unused-import 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 app.modules.users.models import User 18 | 19 | 20 | class OAuth2Client(db.Model): 21 | """ 22 | Model that binds OAuth2 Client ID and Secret to a specific User. 23 | """ 24 | 25 | __tablename__ = 'oauth2_client' 26 | 27 | client_id = db.Column(db.String(length=40), primary_key=True) 28 | client_secret = db.Column(db.String(length=55), nullable=False) 29 | 30 | user_id = db.Column(db.ForeignKey('user.id', ondelete='CASCADE'), index=True, nullable=False) 31 | user = db.relationship(User) 32 | 33 | class ClientTypes(str, enum.Enum): 34 | public = 'public' 35 | confidential = 'confidential' 36 | 37 | client_type = db.Column(db.Enum(ClientTypes), default=ClientTypes.public, nullable=False) 38 | redirect_uris = db.Column(ScalarListType(separator=' '), default=[], nullable=False) 39 | default_scopes = db.Column(ScalarListType(separator=' '), nullable=False) 40 | 41 | @property 42 | def default_redirect_uri(self): 43 | redirect_uris = self.redirect_uris 44 | if redirect_uris: 45 | return redirect_uris[0] 46 | return None 47 | 48 | @classmethod 49 | def find(cls, client_id): 50 | if not client_id: 51 | return None 52 | return cls.query.get(client_id) 53 | 54 | def validate_scopes(self, scopes): 55 | # The only reason for this override is that Swagger UI has a bug which leads to that 56 | # `scope` parameter contains extra spaces between scopes: 57 | # https://github.com/frol/flask-restplus-server-example/issues/131 58 | return set(self.default_scopes).issuperset(set(scopes) - {''}) 59 | 60 | 61 | class OAuth2Grant(db.Model): 62 | """ 63 | Intermediate temporary helper for OAuth2 Grants. 64 | """ 65 | 66 | __tablename__ = 'oauth2_grant' 67 | 68 | id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 69 | 70 | user_id = db.Column(db.ForeignKey('user.id', ondelete='CASCADE'), index=True, nullable=False) 71 | user = db.relationship('User') 72 | 73 | client_id = db.Column( 74 | db.String(length=40), 75 | db.ForeignKey('oauth2_client.client_id'), 76 | index=True, 77 | nullable=False, 78 | ) 79 | client = db.relationship('OAuth2Client') 80 | 81 | code = db.Column(db.String(length=255), index=True, nullable=False) 82 | 83 | redirect_uri = db.Column(db.String(length=255), nullable=False) 84 | expires = db.Column(db.DateTime, nullable=False) 85 | 86 | scopes = db.Column(ScalarListType(separator=' '), nullable=False) 87 | 88 | def delete(self): 89 | db.session.delete(self) 90 | db.session.commit() 91 | return self 92 | 93 | @classmethod 94 | def find(cls, client_id, code): 95 | return cls.query.filter_by(client_id=client_id, code=code).first() 96 | 97 | 98 | class OAuth2Token(db.Model): 99 | """ 100 | OAuth2 Access Tokens storage model. 101 | """ 102 | 103 | __tablename__ = 'oauth2_token' 104 | 105 | id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 106 | client_id = db.Column( 107 | db.String(length=40), 108 | db.ForeignKey('oauth2_client.client_id'), 109 | index=True, 110 | nullable=False, 111 | ) 112 | client = db.relationship('OAuth2Client') 113 | 114 | user_id = db.Column(db.ForeignKey('user.id', ondelete='CASCADE'), index=True, nullable=False) 115 | user = db.relationship('User') 116 | 117 | class TokenTypes(str, enum.Enum): 118 | # currently only bearer is supported 119 | Bearer = 'Bearer' 120 | token_type = db.Column(db.Enum(TokenTypes), nullable=False) 121 | 122 | access_token = db.Column(db.String(length=255), unique=True, nullable=False) 123 | refresh_token = db.Column(db.String(length=255), unique=True, nullable=True) 124 | expires = db.Column(db.DateTime, nullable=False) 125 | scopes = db.Column(ScalarListType(separator=' '), nullable=False) 126 | 127 | @classmethod 128 | def find(cls, access_token=None, refresh_token=None): 129 | if access_token: 130 | return cls.query.filter_by(access_token=access_token).first() 131 | if refresh_token: 132 | return cls.query.filter_by(refresh_token=refresh_token).first() 133 | return None 134 | 135 | def delete(self): 136 | with db.session.begin(): 137 | db.session.delete(self) 138 | -------------------------------------------------------------------------------- /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._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 | if 'user_id' in args: 43 | oauth2_clients = oauth2_clients.filter( 44 | OAuth2Client.user_id == args['user_id'] 45 | ) 46 | return oauth2_clients.offset(args['offset']).limit(args['limit']) 47 | 48 | @api.login_required(oauth_scopes=['auth:write']) 49 | @api.parameters(parameters.CreateOAuth2ClientParameters()) 50 | @api.response(schemas.DetailedOAuth2ClientSchema()) 51 | @api.response(code=HTTPStatus.FORBIDDEN) 52 | @api.response(code=HTTPStatus.CONFLICT) 53 | @api.doc(id='create_oauth_client') 54 | def post(self, args): 55 | """ 56 | Create a new OAuth2 Client. 57 | 58 | Essentially, OAuth2 Client is a ``client_id`` and ``client_secret`` 59 | pair associated with a user. 60 | """ 61 | with api.commit_or_abort( 62 | db.session, 63 | default_error_message="Failed to create a new OAuth2 client." 64 | ): 65 | # TODO: reconsider using gen_salt 66 | new_oauth2_client = OAuth2Client( 67 | user_id=current_user.id, 68 | client_id=security.gen_salt(40), 69 | client_secret=security.gen_salt(50), 70 | **args 71 | ) 72 | db.session.add(new_oauth2_client) 73 | return new_oauth2_client 74 | -------------------------------------------------------------------------------- /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 | class Meta: 22 | # pylint: disable=missing-docstring 23 | model = OAuth2Client 24 | fields = ( 25 | OAuth2Client.user_id.key, 26 | OAuth2Client.client_id.key, 27 | OAuth2Client.client_type.key, 28 | OAuth2Client.default_scopes.key, 29 | OAuth2Client.redirect_uris.key, 30 | ) 31 | dump_only = ( 32 | OAuth2Client.user_id.key, 33 | OAuth2Client.client_id.key, 34 | ) 35 | 36 | 37 | class DetailedOAuth2ClientSchema(BaseOAuth2ClientSchema): 38 | """ 39 | Detailed OAuth2 client schema exposes all useful fields. 40 | """ 41 | 42 | class Meta(BaseOAuth2ClientSchema.Meta): 43 | fields = BaseOAuth2ClientSchema.Meta.fields + ( 44 | OAuth2Client.client_secret.key, 45 | ) 46 | -------------------------------------------------------------------------------- /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._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.query.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-import 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 | 11 | 12 | class TeamMember(db.Model): 13 | """ 14 | Team-member database model. 15 | """ 16 | __tablename__ = 'team_member' 17 | 18 | team_id = db.Column(db.Integer, db.ForeignKey('team.id'), primary_key=True) 19 | team = db.relationship( 20 | 'Team', 21 | backref=db.backref('members', cascade='delete, delete-orphan') 22 | ) 23 | user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True) 24 | user = db.relationship( 25 | 'User', 26 | backref=db.backref('teams_membership', cascade='delete, delete-orphan') 27 | ) 28 | 29 | is_leader = db.Column(db.Boolean(name='is_leader'), default=False, nullable=False) 30 | 31 | __table_args__ = ( 32 | db.UniqueConstraint(team_id, user_id), 33 | ) 34 | 35 | def __repr__(self): 36 | return ( 37 | "<{class_name}(" 38 | "team_id={self.team_id}, " 39 | "user_id=\"{self.user_id}\", " 40 | "is_leader=\"{self.is_leader}\"" 41 | ")>".format( 42 | class_name=self.__class__.__name__, 43 | self=self 44 | ) 45 | ) 46 | 47 | def check_owner(self, user): 48 | return self.user == user 49 | 50 | def check_supervisor(self, user): 51 | return self.team.check_owner(user) 52 | 53 | 54 | class Team(db.Model, Timestamp): 55 | """ 56 | Team database model. 57 | """ 58 | 59 | id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 60 | title = db.Column(db.String(length=50), nullable=False) 61 | 62 | def __repr__(self): 63 | return ( 64 | "<{class_name}(" 65 | "id={self.id}, " 66 | "title=\"{self.title}\"" 67 | ")>".format( 68 | class_name=self.__class__.__name__, 69 | self=self 70 | ) 71 | ) 72 | 73 | @db.validates('title') 74 | def validate_title(self, key, title): # pylint: disable=unused-argument,no-self-use 75 | if len(title) < 3: 76 | raise ValueError("Title has to be at least 3 characters long.") 77 | return title 78 | 79 | def check_owner(self, user): 80 | """ 81 | This is a helper method for OwnerRolePermission integration. 82 | """ 83 | if db.session.query( 84 | TeamMember.query.filter_by(team=self, is_leader=True, user=user).exists() 85 | ).scalar(): 86 | return True 87 | return False 88 | -------------------------------------------------------------------------------- /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 | pass 18 | 19 | 20 | class PatchTeamDetailsParameters(PatchJSONParameters): 21 | # pylint: disable=abstract-method,missing-docstring 22 | OPERATION_CHOICES = ( 23 | PatchJSONParameters.OP_REPLACE, 24 | ) 25 | 26 | PATH_CHOICES = tuple( 27 | '/%s' % field for field in ( 28 | Team.title.key, 29 | ) 30 | ) 31 | 32 | 33 | class AddTeamMemberParameters(PostFormParameters): 34 | user_id = base_fields.Integer(required=True) 35 | is_leader = base_fields.Boolean(required=False) 36 | -------------------------------------------------------------------------------- /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 | Team.id.key, 25 | Team.title.key, 26 | ) 27 | dump_only = ( 28 | Team.id.key, 29 | ) 30 | 31 | 32 | class DetailedTeamSchema(BaseTeamSchema): 33 | """ 34 | Detailed team schema exposes all useful fields. 35 | """ 36 | 37 | members = base_fields.Nested( 38 | 'BaseTeamMemberSchema', 39 | exclude=(TeamMember.team.key, ), 40 | many=True 41 | ) 42 | 43 | class Meta(BaseTeamSchema.Meta): 44 | fields = BaseTeamSchema.Meta.fields + ( 45 | Team.members.key, 46 | Team.created.key, 47 | Team.updated.key, 48 | ) 49 | 50 | 51 | class BaseTeamMemberSchema(ModelSchema): 52 | 53 | team = base_fields.Nested(BaseTeamSchema) 54 | user = base_fields.Nested(BaseUserSchema) 55 | 56 | class Meta: 57 | model = TeamMember 58 | fields = ( 59 | TeamMember.team.key, 60 | TeamMember.user.key, 61 | TeamMember.is_leader.key, 62 | ) 63 | -------------------------------------------------------------------------------- /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 # pylint: disable=unused-import 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 sqlalchemy_utils import types as column_types, Timestamp 9 | 10 | from app.extensions import db 11 | 12 | 13 | def _get_is_static_role_property(role_name, static_role): 14 | """ 15 | A helper function that aims to provide a property getter and setter 16 | for static roles. 17 | 18 | Args: 19 | role_name (str) 20 | static_role (int) - a bit mask for a specific role 21 | 22 | Returns: 23 | property_method (property) - preconfigured getter and setter property 24 | for accessing role. 25 | """ 26 | @property 27 | def _is_static_role_property(self): 28 | return self.has_static_role(static_role) 29 | 30 | @_is_static_role_property.setter 31 | def _is_static_role_property(self, value): 32 | if value: 33 | self.set_static_role(static_role) 34 | else: 35 | self.unset_static_role(static_role) 36 | 37 | _is_static_role_property.fget.__name__ = role_name 38 | return _is_static_role_property 39 | 40 | 41 | class User(db.Model, Timestamp): 42 | """ 43 | User database model. 44 | """ 45 | 46 | id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 47 | username = db.Column(db.String(length=80), unique=True, nullable=False) 48 | password = db.Column( 49 | column_types.PasswordType( 50 | max_length=128, 51 | schemes=('bcrypt', ) 52 | ), 53 | nullable=False 54 | ) 55 | email = db.Column(db.String(length=120), unique=True, nullable=False) 56 | 57 | first_name = db.Column(db.String(length=30), default='', nullable=False) 58 | middle_name = db.Column(db.String(length=30), default='', nullable=False) 59 | last_name = db.Column(db.String(length=30), default='', nullable=False) 60 | 61 | class StaticRoles(enum.Enum): 62 | # pylint: disable=missing-docstring,unsubscriptable-object 63 | INTERNAL = (0x8000, "Internal") 64 | ADMIN = (0x4000, "Admin") 65 | REGULAR_USER = (0x2000, "Regular User") 66 | ACTIVE = (0x1000, "Active Account") 67 | 68 | @property 69 | def mask(self): 70 | return self.value[0] 71 | 72 | @property 73 | def title(self): 74 | return self.value[1] 75 | 76 | static_roles = db.Column(db.Integer, default=0, nullable=False) 77 | 78 | is_internal = _get_is_static_role_property('is_internal', StaticRoles.INTERNAL) 79 | is_admin = _get_is_static_role_property('is_admin', StaticRoles.ADMIN) 80 | is_regular_user = _get_is_static_role_property('is_regular_user', StaticRoles.REGULAR_USER) 81 | is_active = _get_is_static_role_property('is_active', StaticRoles.ACTIVE) 82 | 83 | def __repr__(self): 84 | return ( 85 | "<{class_name}(" 86 | "id={self.id}, " 87 | "username=\"{self.username}\", " 88 | "email=\"{self.email}\", " 89 | "is_internal={self.is_internal}, " 90 | "is_admin={self.is_admin}, " 91 | "is_regular_user={self.is_regular_user}, " 92 | "is_active={self.is_active}, " 93 | ")>".format( 94 | class_name=self.__class__.__name__, 95 | self=self 96 | ) 97 | ) 98 | 99 | def has_static_role(self, role): 100 | return (self.static_roles & role.mask) != 0 101 | 102 | def set_static_role(self, role): 103 | if self.has_static_role(role): 104 | return 105 | self.static_roles |= role.mask 106 | 107 | def unset_static_role(self, role): 108 | if not self.has_static_role(role): 109 | return 110 | self.static_roles ^= role.mask 111 | 112 | def check_owner(self, user): 113 | return self == user 114 | 115 | @property 116 | def is_authenticated(self): 117 | return True 118 | 119 | @property 120 | def is_anonymous(self): 121 | return False 122 | 123 | @classmethod 124 | def find_with_password(cls, username, password): 125 | """ 126 | Args: 127 | username (str) 128 | password (str) - plain-text password 129 | 130 | Returns: 131 | user (User) - if there is a user with a specified username and 132 | password, None otherwise. 133 | """ 134 | user = cls.query.filter_by(username=username).first() 135 | if not user: 136 | return None 137 | if user.password == password: 138 | return user 139 | return None 140 | -------------------------------------------------------------------------------- /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._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.key, 74 | User.middle_name.key, 75 | User.last_name.key, 76 | User.password.key, 77 | User.email.key, 78 | User.is_active.fget.__name__, 79 | User.is_regular_user.fget.__name__, 80 | User.is_admin.fget.__name__, 81 | ) 82 | ) 83 | 84 | @classmethod 85 | def test(cls, obj, field, value, state): 86 | """ 87 | Additional check for 'current_password' as User hasn't field 'current_password' 88 | """ 89 | if field == 'current_password': 90 | if current_user.password != value and obj.password != value: # pylint: disable=consider-using-in 91 | abort(code=HTTPStatus.FORBIDDEN, message="Wrong password") 92 | else: 93 | state['current_password'] = value 94 | return True 95 | return PatchJSONParameters.test(obj, field, value, state) 96 | 97 | @classmethod 98 | def replace(cls, obj, field, value, state): 99 | """ 100 | Some fields require extra permissions to be changed. 101 | 102 | Changing `is_active` and `is_regular_user` properties, current user 103 | must be a supervisor of the changing user, and `current_password` of 104 | the current user should be provided. 105 | 106 | Changing `is_admin` property requires current user to be Admin, and 107 | `current_password` of the current user should be provided.. 108 | """ 109 | if 'current_password' not in state: 110 | raise ValidationError( 111 | "Updating sensitive user settings requires `current_password` test operation " 112 | "performed before replacements." 113 | ) 114 | 115 | if field in {User.is_active.fget.__name__, User.is_regular_user.fget.__name__}: 116 | with permissions.SupervisorRolePermission( 117 | obj=obj, 118 | password_required=True, 119 | password=state['current_password'] 120 | ): 121 | # Access granted 122 | pass 123 | elif field == User.is_admin.fget.__name__: 124 | with permissions.AdminRolePermission( 125 | password_required=True, 126 | password=state['current_password'] 127 | ): 128 | # Access granted 129 | pass 130 | return super(PatchUserDetailsParameters, cls).replace(obj, field, value, state) 131 | -------------------------------------------------------------------------------- /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._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 | return None 49 | 50 | 51 | class AllowAllRule(Rule): 52 | """ 53 | Helper rule that always grants access. 54 | """ 55 | 56 | def check(self): 57 | return True 58 | 59 | 60 | class WriteAccessRule(DenyAbortMixin, Rule): 61 | """ 62 | Ensure that the current_user has has write access. 63 | """ 64 | 65 | def check(self): 66 | return current_user.is_regular_user 67 | 68 | 69 | class ActiveUserRoleRule(DenyAbortMixin, Rule): 70 | """ 71 | Ensure that the current_user is activated. 72 | """ 73 | 74 | def check(self): 75 | # Do not override DENY_ABORT_HTTP_CODE because inherited classes will 76 | # better use HTTP 403/Forbidden code on denial. 77 | self.DENY_ABORT_HTTP_CODE = HTTPStatus.UNAUTHORIZED 78 | # NOTE: `is_active` implies `is_authenticated`. 79 | return current_user.is_active 80 | 81 | 82 | class PasswordRequiredRule(DenyAbortMixin, Rule): 83 | """ 84 | Ensure that the current user has provided a correct password. 85 | """ 86 | 87 | def __init__(self, password, **kwargs): 88 | super(PasswordRequiredRule, self).__init__(**kwargs) 89 | self._password = password 90 | 91 | def check(self): 92 | return current_user.password == self._password 93 | 94 | 95 | class AdminRoleRule(ActiveUserRoleRule): 96 | """ 97 | Ensure that the current_user has an Admin role. 98 | """ 99 | 100 | def check(self): 101 | return current_user.is_admin 102 | 103 | 104 | class InternalRoleRule(ActiveUserRoleRule): 105 | """ 106 | Ensure that the current_user has an Internal role. 107 | """ 108 | 109 | def check(self): 110 | return current_user.is_internal 111 | 112 | 113 | class PartialPermissionDeniedRule(Rule): 114 | """ 115 | Helper rule that must fail on every check since it should never be checked. 116 | """ 117 | 118 | def check(self): 119 | raise RuntimeError("Partial permissions are not intended to be checked") 120 | 121 | 122 | class SupervisorRoleRule(ActiveUserRoleRule): 123 | """ 124 | Ensure that the current_user has a Supervisor access to the given object. 125 | """ 126 | 127 | def __init__(self, obj, **kwargs): 128 | super(SupervisorRoleRule, self).__init__(**kwargs) 129 | self._obj = obj 130 | 131 | def check(self): 132 | if not hasattr(self._obj, 'check_supervisor'): 133 | return False 134 | return self._obj.check_supervisor(current_user) is True 135 | 136 | 137 | class OwnerRoleRule(ActiveUserRoleRule): 138 | """ 139 | Ensure that the current_user has an Owner access to the given object. 140 | """ 141 | 142 | def __init__(self, obj, **kwargs): 143 | super(OwnerRoleRule, self).__init__(**kwargs) 144 | self._obj = obj 145 | 146 | def check(self): 147 | if not hasattr(self._obj, 'check_owner'): 148 | return False 149 | return self._obj.check_owner(current_user) is True 150 | -------------------------------------------------------------------------------- /app/modules/users/resources.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=too-few-public-methods 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._http import HTTPStatus 13 | 14 | from app.extensions.api import Namespace 15 | 16 | from . import permissions, schemas, parameters 17 | from .models import db, User 18 | 19 | 20 | log = logging.getLogger(__name__) 21 | api = Namespace('users', description="Users") 22 | 23 | 24 | @api.route('/') 25 | class Users(Resource): 26 | """ 27 | Manipulations with users. 28 | """ 29 | 30 | @api.login_required(oauth_scopes=['users:read']) 31 | @api.permission_required(permissions.AdminRolePermission()) 32 | @api.response(schemas.BaseUserSchema(many=True)) 33 | @api.paginate() 34 | def get(self, args): 35 | """ 36 | List of users. 37 | 38 | Returns a list of users starting from ``offset`` limited by ``limit`` 39 | parameter. 40 | """ 41 | return User.query.offset(args['offset']).limit(args['limit']) 42 | 43 | @api.parameters(parameters.AddUserParameters()) 44 | @api.response(schemas.DetailedUserSchema()) 45 | @api.response(code=HTTPStatus.FORBIDDEN) 46 | @api.response(code=HTTPStatus.CONFLICT) 47 | @api.doc(id='create_user') 48 | def post(self, args): 49 | """ 50 | Create a new user. 51 | """ 52 | with api.commit_or_abort( 53 | db.session, 54 | default_error_message="Failed to create a new user." 55 | ): 56 | new_user = User(**args) 57 | db.session.add(new_user) 58 | return new_user 59 | 60 | 61 | @api.route('/signup-form') 62 | class UserSignupForm(Resource): 63 | """ 64 | Use signup form helpers. 65 | """ 66 | 67 | @api.response(schemas.UserSignupFormSchema()) 68 | def get(self): 69 | """ 70 | Get signup form keys. 71 | 72 | This endpoint must be used in order to get a server reCAPTCHA public key which 73 | must be used to receive a reCAPTCHA secret key for POST /users/ form. 74 | """ 75 | # TODO: 76 | return {"recaptcha_server_key": "TODO"} 77 | 78 | 79 | @api.route('/') 80 | @api.login_required(oauth_scopes=['users:read']) 81 | @api.response( 82 | code=HTTPStatus.NOT_FOUND, 83 | description="User not found.", 84 | ) 85 | @api.resolve_object_by_model(User, 'user') 86 | class UserByID(Resource): 87 | """ 88 | Manipulations with a specific user. 89 | """ 90 | 91 | @api.permission_required( 92 | permissions.OwnerRolePermission, 93 | kwargs_on_request=lambda kwargs: {'obj': kwargs['user']} 94 | ) 95 | @api.response(schemas.DetailedUserSchema()) 96 | def get(self, user): 97 | """ 98 | Get user details by ID. 99 | """ 100 | return user 101 | 102 | @api.login_required(oauth_scopes=['users:write']) 103 | @api.permission_required( 104 | permissions.OwnerRolePermission, 105 | kwargs_on_request=lambda kwargs: {'obj': kwargs['user']} 106 | ) 107 | @api.permission_required(permissions.WriteAccessPermission()) 108 | @api.parameters(parameters.PatchUserDetailsParameters()) 109 | @api.response(schemas.DetailedUserSchema()) 110 | @api.response(code=HTTPStatus.CONFLICT) 111 | def patch(self, args, user): 112 | """ 113 | Patch user details by ID. 114 | """ 115 | with api.commit_or_abort( 116 | db.session, 117 | default_error_message="Failed to update user details." 118 | ): 119 | parameters.PatchUserDetailsParameters.perform_patch(args, user) 120 | db.session.merge(user) 121 | return user 122 | 123 | 124 | @api.route('/me') 125 | @api.login_required(oauth_scopes=['users:read']) 126 | class UserMe(Resource): 127 | """ 128 | Useful reference to the authenticated user itself. 129 | """ 130 | 131 | @api.response(schemas.DetailedUserSchema()) 132 | def get(self): 133 | """ 134 | Get current user details. 135 | """ 136 | return User.query.get_or_404(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 | User.id.key, 24 | User.username.key, 25 | User.first_name.key, 26 | User.middle_name.key, 27 | User.last_name.key, 28 | ) 29 | dump_only = ( 30 | User.id.key, 31 | ) 32 | 33 | 34 | class DetailedUserSchema(BaseUserSchema): 35 | """ 36 | Detailed user schema exposes all useful fields. 37 | """ 38 | 39 | class Meta(BaseUserSchema.Meta): 40 | fields = BaseUserSchema.Meta.fields + ( 41 | User.email.key, 42 | User.created.key, 43 | User.updated.key, 44 | User.is_active.fget.__name__, 45 | User.is_regular_user.fget.__name__, 46 | User.is_admin.fget.__name__, 47 | ) 48 | 49 | 50 | class UserSignupFormSchema(Schema): 51 | 52 | recaptcha_server_key = base_fields.String(required=True) 53 | -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=1.1,<2 2 | 3 | flask-restplus>=0.11.0,<0.12 4 | 5 | Flask-Cors>=3.0.8,<4 6 | 7 | SQLAlchemy>=1.3.0,<2 8 | SQLAlchemy-Utils>=0.34,<0.35 9 | Flask-SQLAlchemy>=2.4,<3 10 | Alembic>=1.0,<2 11 | werkzeug>=0.15,<0.16 12 | 13 | marshmallow>=2.13.5,<3 14 | flask-marshmallow>=0.7,<0.8 15 | marshmallow-sqlalchemy>=0.12,<0.13 16 | webargs>=1.4.0,<2 17 | apispec>=0.20.0,<0.39 18 | 19 | bcrypt>=3.1.3,<4 20 | passlib>=1.7.1,<2 21 | Flask-OAuthlib>=0.9.4,<0.10 22 | Flask-Login>=0.4.0,<0.5 23 | permission>=0.4.1,<0.5 24 | 25 | arrow>=0.8.0,<0.9 26 | 27 | six 28 | enum34; python_version < '3.4' 29 | -------------------------------------------------------------------------------- /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-css.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 10 | 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /clients/javascript/.npmignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src/.* 3 | -------------------------------------------------------------------------------- /clients/javascript/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mhart/alpine-node 2 | 3 | ENV HOME=/tmp 4 | WORKDIR /opt 5 | 6 | COPY "./.npmignore" "./" 7 | COPY "./dist/" "./" 8 | 9 | USER nobody 10 | -------------------------------------------------------------------------------- /clients/javascript/swagger_codegen_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "flask-restplus-server-example-client", 3 | "moduleName": "FlaskRESTplusServerExample", 4 | "projectLicenseName": "MIT", 5 | "usePromises": true 6 | } 7 | -------------------------------------------------------------------------------- /clients/python/swagger_codegen_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "example-client", 3 | "packageName": "example_client" 4 | } 5 | -------------------------------------------------------------------------------- /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 | REVERSE_PROXY_SETUP = os.getenv('EXAMPLE_API_REVERSE_PROXY_SETUP', False) 31 | 32 | AUTHORIZATIONS = { 33 | 'oauth2_password': { 34 | 'type': 'oauth2', 35 | 'flow': 'password', 36 | 'scopes': {}, 37 | 'tokenUrl': '/auth/oauth2/token', 38 | }, 39 | # TODO: implement other grant types for third-party apps 40 | #'oauth2_implicit': { 41 | # 'type': 'oauth2', 42 | # 'flow': 'implicit', 43 | # 'scopes': {}, 44 | # 'authorizationUrl': '/auth/oauth2/authorize', 45 | #}, 46 | } 47 | 48 | ENABLED_MODULES = ( 49 | 'auth', 50 | 51 | 'users', 52 | 'teams', 53 | 54 | 'api', 55 | ) 56 | 57 | STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') 58 | 59 | SWAGGER_UI_JSONEDITOR = True 60 | SWAGGER_UI_OAUTH_CLIENT_ID = 'documentation' 61 | SWAGGER_UI_OAUTH_REALM = "Authentication for Flask-RESTplus Example server documentation" 62 | SWAGGER_UI_OAUTH_APP_NAME = "Flask-RESTplus Example server documentation" 63 | 64 | # TODO: consider if these are relevant for this project 65 | SQLALCHEMY_TRACK_MODIFICATIONS = True 66 | CSRF_ENABLED = True 67 | 68 | 69 | class ProductionConfig(BaseConfig): 70 | SECRET_KEY = os.getenv('EXAMPLE_API_SERVER_SECRET_KEY') 71 | SQLALCHEMY_DATABASE_URI = os.getenv('EXAMPLE_API_SERVER_SQLALCHEMY_DATABASE_URI') 72 | 73 | 74 | class DevelopmentConfig(BaseConfig): 75 | DEBUG = True 76 | 77 | 78 | class TestingConfig(BaseConfig): 79 | TESTING = True 80 | 81 | # Use in-memory SQLite database for testing 82 | SQLALCHEMY_DATABASE_URI = 'sqlite://' 83 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/conftest.py -------------------------------------------------------------------------------- /deploy/README.md: -------------------------------------------------------------------------------- 1 | RESTful API Server Example deployments 2 | ====================================== 3 | 4 | This folder features a few deployment strategies for the [example RESTful API server](../api/). 5 | 6 | Index 7 | ----- 8 | 9 | * `stack1` - RESTful API Server Example behind an nginx reverse proxy, stack 10 | managed using Docker Compose. 11 | * `stack2` - stack1 running on uWSGI instead of the default Flask server. 12 | 13 | Tips 14 | ---- 15 | 16 | * It is advisable to run web services over HTTPS instead of HTTP. Take a look at 17 | [Let's Encrypt](https://letsencrypt.org/) if you don't want to pay money for 18 | SSL certificates. 19 | * It makes a lot of sense to use a reliable Reverse Proxy server in front of 20 | Flask (or any other framework, in fact). My choice is Nginx. 21 | * Using a Reverse Proxy, you should enable `REVERSE_PROXY_SETUP` in the config 22 | or pass `EXAMPLE_API_REVERSE_PROXY_SETUP=1` via environment variables (the 23 | showcased stacks do that, so you don't need to do that if you follow the 24 | guide), so special proxyfied headers (`X-Forwarded-Proto`, and others) are 25 | taken into account. 26 | -------------------------------------------------------------------------------- /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 | environment: 13 | EXAMPLE_API_REVERSE_PROXY_SETUP: 'true' 14 | FLASK_CONFIG: 'production' 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 | -------------------------------------------------------------------------------- /deploy/stack2/README.md: -------------------------------------------------------------------------------- 1 | Stack 2 - Docker Compose topology + nginx reverse proxy using uWSGI 2 | =================================================================== 3 | 4 | This stack showcases an implementation of the RESTful API server running as 5 | uWSGI server in a 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/stack2 50 | $ docker-compose build 51 | $ docker-compose up 52 | ``` 53 | 54 | To tear it down: 55 | 56 | ```bash 57 | $ docker-compose kill 58 | $ docker-compose rm -fv 59 | ``` 60 | 61 | Quickstart 62 | ---------- 63 | 64 | Open reverse proxy home page 65 | [http://127.0.0.1/](http://127.0.0.1/) or http://127.0.0.1:CUSTOM_PORT if you changed it 66 | 67 | Open online interactive API documentation: 68 | [http://127.0.0.1/api/v1/](http://127.0.0.1/api/v1/) 69 | 70 | Autogenerated swagger config is always available from 71 | [http://127.0.0.1:5000/api/v1/swagger.json](http://127.0.0.1:5000/api/v1/swagger.json) 72 | 73 | This stack is based on the [stack1](../stack1/), so you may find some extra information there. 74 | 75 | Read the [RESTful API Server Example](../../api/) doc for more information about the API itself. 76 | 77 | Enhancement 78 | ---------- 79 | 80 | **Use the uWSGI way to log:** Append `uwsgi` command with `--logger file:logfile=/tmp/flask-stack2.log,maxsize=104857600` (Log to /tmp/flask-stack2.log.{timestamp}, Use timestamp if file size over 100MB) in `docker-compose.yml`. 81 | -------------------------------------------------------------------------------- /deploy/stack2/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | revproxy: 5 | restart: always 6 | build: ./revproxy 7 | ports: 8 | - "80:80" 9 | links: 10 | - api:api 11 | 12 | api: 13 | restart: always 14 | build: 15 | context: ../../ 16 | args: 17 | INCLUDE_UWSGI: 'true' 18 | environment: 19 | EXAMPLE_API_REVERSE_PROXY_SETUP: 'true' 20 | FLASK_CONFIG: 'production' 21 | command: 'uwsgi --need-app --manage-script-name --mount /=app:create_app() --uwsgi-socket 0.0.0.0:5000' 22 | -------------------------------------------------------------------------------- /deploy/stack2/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/stack2/revproxy/conf.d/default.conf: -------------------------------------------------------------------------------- 1 | include uwsgi_params; 2 | 3 | server { 4 | listen 80 default_server; 5 | server_name _; 6 | charset utf-8; 7 | 8 | # Rules could be optimized but as is it could help others to understand and customise them. 9 | location /api/ { 10 | uwsgi_pass api:5000; 11 | } 12 | location /auth/ { 13 | uwsgi_pass api:5000; 14 | } 15 | location /swaggerui/ { 16 | uwsgi_pass api:5000; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /deploy/stack2/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/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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, DefaultHTTPErrorSchema 4 | try: 5 | from .model import ModelSchema 6 | except ImportError: 7 | pass 8 | from .namespace import Namespace 9 | from .parameters import Parameters, PostFormParameters, PatchJSONParameters 10 | from .swagger import Swagger 11 | from .resource import Resource 12 | -------------------------------------------------------------------------------- /flask_restplus_patched/api.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | from flask_restplus import Api as OriginalApi 3 | from flask_restplus._http import HTTPStatus 4 | from werkzeug import cached_property 5 | 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, **kwargs): 18 | # This solves the issue of late resources registration: 19 | # https://github.com/frol/flask-restplus-server-example/issues/110 20 | # https://github.com/noirbizarre/flask-restplus/pull/483 21 | self.app = app 22 | 23 | super(Api, self).init_app(app, **kwargs) 24 | app.errorhandler(HTTPStatus.UNPROCESSABLE_ENTITY.value)(handle_validation_error) 25 | 26 | def namespace(self, *args, **kwargs): 27 | # The only purpose of this method is to pass a custom Namespace class 28 | _namespace = Namespace(*args, **kwargs) 29 | self.add_namespace(_namespace) 30 | return _namespace 31 | 32 | 33 | # Return validation errors as JSON 34 | def handle_validation_error(err): 35 | exc = err.data['exc'] 36 | return jsonify({ 37 | 'status': HTTPStatus.UNPROCESSABLE_ENTITY.value, 38 | 'message': exc.messages 39 | }), HTTPStatus.UNPROCESSABLE_ENTITY.value 40 | -------------------------------------------------------------------------------- /flask_restplus_patched/model.py: -------------------------------------------------------------------------------- 1 | from apispec.ext.marshmallow.swagger import fields2jsonschema, field2property 2 | import flask_marshmallow 3 | from werkzeug import cached_property 4 | 5 | from flask_restplus.model import Model as OriginalModel 6 | 7 | 8 | class SchemaMixin(object): 9 | 10 | def __deepcopy__(self, memo): 11 | # XXX: Flask-RESTplus makes unnecessary data copying, while 12 | # marshmallow.Schema doesn't support deepcopyng. 13 | return self 14 | 15 | 16 | class Schema(SchemaMixin, flask_marshmallow.Schema): 17 | pass 18 | 19 | 20 | if flask_marshmallow.has_sqla: 21 | class ModelSchema(SchemaMixin, flask_marshmallow.sqla.ModelSchema): 22 | pass 23 | 24 | 25 | class DefaultHTTPErrorSchema(Schema): 26 | status = flask_marshmallow.base_fields.Integer() 27 | message = flask_marshmallow.base_fields.String() 28 | 29 | def __init__(self, http_code, **kwargs): 30 | super(DefaultHTTPErrorSchema, self).__init__(**kwargs) 31 | self.fields['status'].default = http_code 32 | 33 | 34 | class Model(OriginalModel): 35 | 36 | def __init__(self, name, model, **kwargs): 37 | # XXX: Wrapping with __schema__ is not a very elegant solution. 38 | super(Model, self).__init__(name, {'__schema__': model}, **kwargs) 39 | 40 | @cached_property 41 | def __schema__(self): 42 | schema = self['__schema__'] 43 | if isinstance(schema, flask_marshmallow.Schema): 44 | return fields2jsonschema(schema.fields) 45 | elif isinstance(schema, flask_marshmallow.base_fields.FieldABC): 46 | return field2property(schema) 47 | raise NotImplementedError() 48 | -------------------------------------------------------------------------------- /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 flask_restplus._http import HTTPStatus 6 | from werkzeug.exceptions import HTTPException 7 | 8 | 9 | class Resource(OriginalResource): 10 | """ 11 | Extended Flast-RESTPlus Resource to add options method 12 | """ 13 | 14 | @classmethod 15 | def _apply_decorator_to_methods(cls, decorator): 16 | """ 17 | This helper can apply a given decorator to all methods on the current 18 | Resource. 19 | 20 | NOTE: In contrast to ``Resource.method_decorators``, which has a 21 | similar use-case, this method applies decorators directly and override 22 | methods in-place, while the decorators listed in 23 | ``Resource.method_decorators`` are applied on every request which is 24 | quite a waste of resources. 25 | """ 26 | for method in cls.methods: 27 | method_name = method.lower() 28 | decorated_method_func = decorator(getattr(cls, method_name)) 29 | setattr(cls, method_name, decorated_method_func) 30 | 31 | def options(self, *args, **kwargs): 32 | """ 33 | Check which methods are allowed. 34 | 35 | Use this method if you need to know what operations are allowed to be 36 | performed on this endpoint, e.g. to decide wether to display a button 37 | in your UI. 38 | 39 | The list of allowed methods is provided in `Allow` response header. 40 | """ 41 | # This is a generic implementation of OPTIONS method for resources. 42 | # This method checks every permissions provided as decorators for other 43 | # methods to provide information about what methods `current_user` can 44 | # use. 45 | method_funcs = [getattr(self, m.lower()) for m in self.methods] 46 | allowed_methods = [] 47 | request_oauth_backup = getattr(flask.request, 'oauth', None) 48 | for method_func in method_funcs: 49 | if getattr(method_func, '_access_restriction_decorators', None): 50 | if not hasattr(method_func, '_cached_fake_method_func'): 51 | fake_method_func = lambda *args, **kwargs: True 52 | # `__name__` is used in `login_required` decorator, so it 53 | # is required to fake this also 54 | fake_method_func.__name__ = 'options' 55 | 56 | # Decorate the fake method with the registered access 57 | # restriction decorators 58 | for decorator in method_func._access_restriction_decorators: 59 | fake_method_func = decorator(fake_method_func) 60 | 61 | # Cache the `fake_method_func` to avoid redoing this over 62 | # and over again 63 | method_func.__dict__['_cached_fake_method_func'] = fake_method_func 64 | else: 65 | fake_method_func = method_func._cached_fake_method_func 66 | 67 | flask.request.oauth = None 68 | try: 69 | fake_method_func(self, *args, **kwargs) 70 | except HTTPException: 71 | # This method is not allowed, so skip it 72 | continue 73 | 74 | allowed_methods.append(method_func.__name__.upper()) 75 | flask.request.oauth = request_oauth_backup 76 | 77 | return flask.Response( 78 | status=HTTPStatus.NO_CONTENT, 79 | headers={'Allow': ", ".join(allowed_methods)} 80 | ) 81 | -------------------------------------------------------------------------------- /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/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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 | 11 | logging.basicConfig() 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | #logging.getLogger('app').setLevel(logging.DEBUG) 15 | 16 | try: 17 | import colorlog 18 | except ImportError: 19 | pass 20 | else: 21 | formatter = colorlog.ColoredFormatter( 22 | ( 23 | '%(asctime)s ' 24 | '[%(log_color)s%(levelname)s%(reset)s] ' 25 | '[%(cyan)s%(name)s%(reset)s] ' 26 | '%(message_log_color)s%(message)s' 27 | ), 28 | reset=True, 29 | log_colors={ 30 | 'DEBUG': 'bold_cyan', 31 | 'INFO': 'bold_green', 32 | 'WARNING': 'bold_yellow', 33 | 'ERROR': 'bold_red', 34 | 'CRITICAL': 'bold_red,bg_white', 35 | }, 36 | secondary_log_colors={ 37 | 'message': { 38 | 'DEBUG': 'white', 39 | 'INFO': 'bold_white', 40 | 'WARNING': 'bold_yellow', 41 | 'ERROR': 'bold_red', 42 | 'CRITICAL': 'bold_red', 43 | }, 44 | }, 45 | style='%' 46 | ) 47 | 48 | for handler in logger.handlers: 49 | if isinstance(handler, logging.StreamHandler): 50 | break 51 | else: 52 | handler = logging.StreamHandler() 53 | logger.addHandler(handler) 54 | handler.setFormatter(formatter) 55 | 56 | 57 | from invoke import Collection 58 | from invoke.executor import Executor 59 | 60 | from . import app 61 | 62 | # NOTE: `namespace` or `ns` name is required! 63 | namespace = Collection( 64 | app, 65 | ) 66 | 67 | def invoke_execute(context, command_name, **kwargs): 68 | """ 69 | Helper function to make invoke-tasks execution easier. 70 | """ 71 | results = Executor(namespace, config=context.config).execute((command_name, kwargs)) 72 | target_task = context.root_namespace[command_name] 73 | return results[target_task] 74 | 75 | namespace.configure({ 76 | 'run': { 77 | 'shell': '/bin/sh' if platform.system() != 'Windows' else os.environ.get('COMSPEC'), 78 | }, 79 | 'root_namespace': namespace, 80 | 'invoke_execute': invoke_execute, 81 | }) 82 | -------------------------------------------------------------------------------- /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, boilerplates 9 | 10 | from config import BaseConfig 11 | 12 | namespace = Collection( 13 | dependencies, 14 | env, 15 | db, 16 | run, 17 | users, 18 | swagger, 19 | boilerplates, 20 | ) 21 | 22 | namespace.configure({ 23 | 'app': { 24 | 'static_root': BaseConfig.STATIC_ROOT, 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /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/boilerplates.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=line-too-long 2 | """ 3 | Boilerplates 4 | """ 5 | from __future__ import print_function 6 | 7 | import logging 8 | import os 9 | import re 10 | 11 | try: 12 | from invoke import ctask as task 13 | except ImportError: # Invoke 0.13 renamed ctask to task 14 | from invoke import task 15 | 16 | 17 | log = logging.getLogger(__name__) # pylint: disable=invalid-name 18 | 19 | 20 | @task 21 | def crud_module(context, module_name='', module_name_singular=''): 22 | # pylint: disable=unused-argument 23 | """ 24 | Create CRUD (Create-Read-Update-Delete) empty module. 25 | 26 | Usage: 27 | $ invoke app.boilerplates.crud-module --module-name=articles --module-name-singular=article 28 | """ 29 | try: 30 | import jinja2 31 | except ImportError: 32 | log.critical("jinja2 is required to create boilerplates. Please, do `pip install jinja2`") 33 | return 34 | 35 | if not module_name: 36 | log.critical("Module name is required") 37 | return 38 | 39 | if not re.match('^[a-zA-Z0-9_]+$', module_name): 40 | log.critical( 41 | "Module module_name is allowed to contain only letters, numbers and underscores " 42 | "([a-zA-Z0-9_]+)" 43 | ) 44 | return 45 | 46 | if not module_name_singular: 47 | module_name_singular = module_name[:-1] 48 | 49 | module_path = 'app/modules/%s' % module_name 50 | 51 | module_title = " ".join( 52 | [word.capitalize() 53 | for word in module_name.split('_') 54 | ] 55 | ) 56 | 57 | model_name = "".join( 58 | [word.capitalize() 59 | for word in module_name_singular.split('_') 60 | ] 61 | ) 62 | 63 | if os.path.exists(module_path): 64 | log.critical('Module `%s` already exists.', module_name) 65 | return 66 | 67 | os.makedirs(module_path) 68 | 69 | env = jinja2.Environment( 70 | loader=jinja2.FileSystemLoader('tasks/app/boilerplates_templates/crud_module') 71 | ) 72 | for template_file in ( 73 | '__init__', 74 | 'models', 75 | 'parameters', 76 | 'resources', 77 | 'schemas', 78 | ): 79 | template = env.get_template('%s.py.template' % template_file) 80 | template.stream( 81 | module_name=module_name, 82 | module_name_singular=module_name_singular, 83 | module_title=module_title, 84 | module_namespace=module_name.replace('_', '-'), 85 | model_name=model_name, 86 | ).dump( 87 | '%s/%s.py' % (module_path, template_file) 88 | ) 89 | 90 | log.info("Module `%s` has been created.", module_name) 91 | print( 92 | "Add `%(module_name)s` to `ENABLED_MODULES` in `config.py`\n" 93 | "ENABLED_MODULES = (\n" 94 | "\t'auth',\n" 95 | "\t'users',\n" 96 | "\t'teams',\n" 97 | "\t'%(module_name)s',\n\n" 98 | "\t'api',\n" 99 | ")\n\n" 100 | "You can find your module at `app/modules/` directory" 101 | % {'module_name': module_name} 102 | ) 103 | -------------------------------------------------------------------------------- /tasks/app/boilerplates_templates/crud_module/__init__.py.template: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | {{ module_title }} 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 {{ module_title }} module. 14 | """ 15 | api_v1.add_oauth_scope('{{ module_namespace }}:read', "Provide access to {{ module_title }} details") 16 | api_v1.add_oauth_scope('{{ module_namespace }}:write', "Provide write access to {{ module_title }} details") 17 | 18 | # Touch underlying modules 19 | from . import models, resources 20 | 21 | api_v1.add_namespace(resources.api) 22 | -------------------------------------------------------------------------------- /tasks/app/boilerplates_templates/crud_module/models.py.template: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | {{ module_title }} database models 4 | -------------------- 5 | """ 6 | 7 | from sqlalchemy_utils import Timestamp 8 | 9 | from app.extensions import db 10 | 11 | 12 | class {{ model_name }}(db.Model, Timestamp): 13 | """ 14 | {{ module_title }} database model. 15 | """ 16 | 17 | id = db.Column(db.Integer, primary_key=True) # pylint: disable=invalid-name 18 | title = db.Column(db.String(length=50), nullable=False) 19 | 20 | def __repr__(self): 21 | return ( 22 | "<{class_name}(" 23 | "id={self.id}, " 24 | "title=\"{self.title}\"" 25 | ")>".format( 26 | class_name=self.__class__.__name__, 27 | self=self 28 | ) 29 | ) 30 | 31 | @db.validates('title') 32 | def validate_title(self, key, title): # pylint: disable=unused-argument,no-self-use 33 | if len(title) < 3: 34 | raise ValueError("Title has to be at least 3 characters long.") 35 | return title 36 | -------------------------------------------------------------------------------- /tasks/app/boilerplates_templates/crud_module/parameters.py.template: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Input arguments (Parameters) for {{ module_title }} 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 {{ model_name }} 12 | 13 | 14 | class Create{{ model_name }}Parameters(PostFormParameters, schemas.Detailed{{ model_name }}Schema): 15 | 16 | class Meta(schemas.Detailed{{ model_name }}Schema.Meta): 17 | pass 18 | 19 | 20 | class Patch{{ model_name }}DetailsParameters(PatchJSONParameters): 21 | # pylint: disable=abstract-method,missing-docstring 22 | OPERATION_CHOICES = ( 23 | PatchJSONParameters.OP_REPLACE, 24 | ) 25 | 26 | PATH_CHOICES = tuple( 27 | '/%s' % field for field in ( 28 | {{ model_name }}.title.key, 29 | ) 30 | ) 31 | -------------------------------------------------------------------------------- /tasks/app/boilerplates_templates/crud_module/resources.py.template: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # pylint: disable=bad-continuation 3 | """ 4 | RESTful API {{ module_title }} 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._http import HTTPStatus 13 | 14 | from app.extensions import db 15 | from app.extensions.api import Namespace, abort 16 | from app.extensions.api.parameters import PaginationParameters 17 | from app.modules.users import permissions 18 | 19 | 20 | from . import parameters, schemas 21 | from .models import {{ model_name }} 22 | 23 | 24 | log = logging.getLogger(__name__) # pylint: disable=invalid-name 25 | api = Namespace('{{ module_namespace }}', description="{{ module_title }}") # pylint: disable=invalid-name 26 | 27 | 28 | @api.route('/') 29 | @api.login_required(oauth_scopes=['{{ module_namespace }}:read']) 30 | class {{ module_title.replace(' ', '') }}(Resource): 31 | """ 32 | Manipulations with {{ module_title }}. 33 | """ 34 | 35 | @api.parameters(PaginationParameters()) 36 | @api.response(schemas.Base{{ model_name }}Schema(many=True)) 37 | def get(self, args): 38 | """ 39 | List of {{ model_name }}. 40 | 41 | Returns a list of {{ model_name }} starting from ``offset`` limited by ``limit`` 42 | parameter. 43 | """ 44 | return {{ model_name }}.query.offset(args['offset']).limit(args['limit']) 45 | 46 | @api.login_required(oauth_scopes=['{{ module_namespace }}:write']) 47 | @api.parameters(parameters.Create{{ model_name }}Parameters()) 48 | @api.response(schemas.Detailed{{ model_name }}Schema()) 49 | @api.response(code=HTTPStatus.CONFLICT) 50 | def post(self, args): 51 | """ 52 | Create a new instance of {{ model_name }}. 53 | """ 54 | with api.commit_or_abort( 55 | db.session, 56 | default_error_message="Failed to create a new {{ model_name }}" 57 | ): 58 | {{ module_name_singular }} = {{ model_name }}(**args) 59 | db.session.add({{ module_name_singular }}) 60 | return {{ module_name_singular }} 61 | 62 | 63 | @api.route('/') 64 | @api.login_required(oauth_scopes=['{{ module_namespace }}:read']) 65 | @api.response( 66 | code=HTTPStatus.NOT_FOUND, 67 | description="{{ model_name }} not found.", 68 | ) 69 | @api.resolve_object_by_model({{ model_name }}, '{{ module_name_singular }}') 70 | class {{ model_name }}ByID(Resource): 71 | """ 72 | Manipulations with a specific {{ model_name }}. 73 | """ 74 | 75 | @api.response(schemas.Detailed{{ model_name }}Schema()) 76 | def get(self, {{ module_name_singular }}): 77 | """ 78 | Get {{ model_name }} details by ID. 79 | """ 80 | return {{ module_name_singular }} 81 | 82 | @api.login_required(oauth_scopes=['{{ module_namespace }}:write']) 83 | @api.permission_required(permissions.WriteAccessPermission()) 84 | @api.parameters(parameters.Patch{{ model_name }}DetailsParameters()) 85 | @api.response(schemas.Detailed{{ model_name }}Schema()) 86 | @api.response(code=HTTPStatus.CONFLICT) 87 | def patch(self, args, {{ module_name_singular }}): 88 | """ 89 | Patch {{ model_name }} details by ID. 90 | """ 91 | with api.commit_or_abort( 92 | db.session, 93 | default_error_message="Failed to update {{ model_name }} details." 94 | ): 95 | parameters.Patch{{ model_name }}DetailsParameters.perform_patch(args, obj={{ module_name_singular }}) 96 | db.session.merge({{ module_name_singular }}) 97 | return {{ module_name_singular }} 98 | 99 | @api.login_required(oauth_scopes=['{{ module_namespace }}:write']) 100 | @api.permission_required(permissions.WriteAccessPermission()) 101 | @api.response(code=HTTPStatus.CONFLICT) 102 | @api.response(code=HTTPStatus.NO_CONTENT) 103 | def delete(self, {{ module_name_singular }}): 104 | """ 105 | Delete a {{ model_name }} by ID. 106 | """ 107 | with api.commit_or_abort( 108 | db.session, 109 | default_error_message="Failed to delete the {{ model_name }}." 110 | ): 111 | db.session.delete({{ module_name_singular }}) 112 | return None 113 | -------------------------------------------------------------------------------- /tasks/app/boilerplates_templates/crud_module/schemas.py.template: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Serialization schemas for {{ module_title }} resources RESTful API 4 | ---------------------------------------------------- 5 | """ 6 | 7 | from flask_marshmallow import base_fields 8 | from flask_restplus_patched import ModelSchema 9 | 10 | from .models import {{ model_name }} 11 | 12 | 13 | class Base{{ model_name }}Schema(ModelSchema): 14 | """ 15 | Base {{ model_name }} schema exposes only the most general fields. 16 | """ 17 | 18 | class Meta: 19 | # pylint: disable=missing-docstring 20 | model = {{ model_name }} 21 | fields = ( 22 | {{ model_name }}.id.key, 23 | {{ model_name }}.title.key, 24 | ) 25 | dump_only = ( 26 | {{ model_name }}.id.key, 27 | ) 28 | 29 | 30 | class Detailed{{ model_name }}Schema(Base{{ model_name }}Schema): 31 | """ 32 | Detailed {{ model_name }} schema exposes all useful fields. 33 | """ 34 | 35 | class Meta(Base{{ model_name }}Schema.Meta): 36 | fields = Base{{ model_name }}Schema.Meta.fields + ( 37 | {{ model_name }}.created.key, 38 | {{ model_name }}.updated.key, 39 | ) 40 | dump_only = Base{{ model_name }}Schema.Meta.dump_only + ( 41 | {{ model_name }}.created.key, 42 | {{ model_name }}.updated.key, 43 | ) 44 | -------------------------------------------------------------------------------- /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 | import logging 5 | 6 | # this is the Alembic Config object, which provides 7 | # access to the values within the .ini file in use. 8 | config = context.config 9 | 10 | # Setup logging 11 | sqlalchemy_logger = logging.getLogger('sqlalchemy') 12 | if sqlalchemy_logger.level == logging.NOTSET: 13 | sqlalchemy_logger.setLevel(logging.WARN) 14 | 15 | alembic_logger = logging.getLogger('alembic') 16 | if alembic_logger.level == logging.NOTSET: 17 | alembic_logger.setLevel(logging.INFO) 18 | 19 | logger = logging.getLogger('alembic.env') 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | from flask import current_app 26 | config.set_main_option('sqlalchemy.url', 27 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure(url=url) 50 | 51 | with context.begin_transaction(): 52 | context.run_migrations() 53 | 54 | 55 | def run_migrations_online(): 56 | """Run migrations in 'online' mode. 57 | 58 | In this scenario we need to create an Engine 59 | and associate a connection with the context. 60 | 61 | """ 62 | 63 | # this callback is used to prevent an auto-migration from being generated 64 | # when there are no changes to the schema 65 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 66 | def process_revision_directives(context, revision, directives): 67 | if getattr(config.cmd_opts, 'autogenerate', False): 68 | script = directives[0] 69 | if script.upgrade_ops.is_empty(): 70 | directives[:] = [] 71 | logger.info('No changes in schema detected.') 72 | 73 | engine = engine_from_config(config.get_section(config.config_ini_section), 74 | prefix='sqlalchemy.', 75 | poolclass=pool.NullPool) 76 | 77 | connection = engine.connect() 78 | context.configure(connection=connection, 79 | target_metadata=target_metadata, 80 | process_revision_directives=process_revision_directives, 81 | **current_app.extensions['migrate'].configure_args) 82 | 83 | try: 84 | with context.begin_transaction(): 85 | context.run_migrations() 86 | finally: 87 | connection.close() 88 | 89 | if context.is_offline_mode(): 90 | run_migrations_offline() 91 | else: 92 | run_migrations_online() 93 | -------------------------------------------------------------------------------- /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 = 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 AttributeError: # 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 | 31 | from werkzeug import script 32 | import flask 33 | 34 | import app 35 | flask_app = app.create_app() 36 | 37 | def shell_context(): 38 | context = dict(pprint=pprint.pprint) 39 | context.update(vars(flask)) 40 | context.update(vars(app)) 41 | return context 42 | 43 | with flask_app.app_context(): 44 | script.make_shell(shell_context, use_ipython=True)() 45 | -------------------------------------------------------------------------------- /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 | uwsgi=False, 30 | uwsgi_mode='http', 31 | uwsgi_extra_options='', 32 | ): 33 | """ 34 | Run Example RESTful API Server. 35 | """ 36 | if flask_config is not None: 37 | os.environ['FLASK_CONFIG'] = flask_config 38 | 39 | if install_dependencies: 40 | context.invoke_execute(context, 'app.dependencies.install') 41 | 42 | from app import create_app 43 | app = create_app() 44 | 45 | if upgrade_db: 46 | # After the installed dependencies the app.db.* tasks might need to be 47 | # reloaded to import all necessary dependencies. 48 | from . import db as db_tasks 49 | reload(db_tasks) 50 | 51 | context.invoke_execute(context, 'app.db.upgrade', app=app) 52 | if app.debug: 53 | context.invoke_execute( 54 | context, 55 | 'app.db.init_development_data', 56 | app=app, 57 | upgrade_db=False, 58 | skip_on_failure=True 59 | ) 60 | 61 | use_reloader = app.debug 62 | if uwsgi: 63 | uwsgi_args = [ 64 | "uwsgi", 65 | "--need-app", 66 | "--manage-script-name", 67 | "--mount", "/=app:create_app()", 68 | "--%s-socket" % uwsgi_mode, "%s:%d" % (host, port), 69 | ] 70 | if use_reloader: 71 | uwsgi_args += ["--python-auto-reload", "2"] 72 | if uwsgi_extra_options: 73 | uwsgi_args += uwsgi_extra_options.split(' ') 74 | os.execvpe('uwsgi', uwsgi_args, os.environ) 75 | else: 76 | if platform.system() == 'Windows': 77 | warnings.warn( 78 | "Auto-reloader feature doesn't work on Windows. " 79 | "Follow the issue for more details: " 80 | "https://github.com/frol/flask-restplus-server-example/issues/16" 81 | ) 82 | use_reloader = False 83 | return app.run(host=host, port=port, use_reloader=use_reloader) 84 | -------------------------------------------------------------------------------- /tasks/app/swagger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Swagger related invoke tasks 3 | """ 4 | from __future__ import print_function 5 | 6 | import logging 7 | import os 8 | 9 | try: 10 | from invoke import ctask as task 11 | except ImportError: # Invoke 0.13 renamed ctask to task 12 | from invoke import task 13 | 14 | 15 | @task(default=True) 16 | def export(context, output_format='json', quiet=False): 17 | """ 18 | Export swagger.json content 19 | """ 20 | # set logging level to ERROR to avoid [INFO] messages in result 21 | logging.getLogger().setLevel(logging.ERROR) 22 | 23 | from app import create_app 24 | app = create_app(flask_config_name='testing') 25 | swagger_content = app.test_client().get('/api/v1/swagger.%s' % output_format).data 26 | if not quiet: 27 | print(swagger_content.decode('utf-8')) 28 | return swagger_content 29 | 30 | 31 | @task 32 | def codegen(context, language, version, dry_run=False, offline=False): 33 | if dry_run: 34 | run = print 35 | else: 36 | run = context.run 37 | 38 | swagger_json_content = export(context, output_format='json', quiet=True) 39 | if dry_run: 40 | run( 41 | "cat >./clients/%(language)s/swagger.json <<'EOF'\n%(swagger_json_content)s\nEOF" 42 | % { 43 | 'language': language, 44 | 'swagger_json_content': swagger_json_content.decode('utf-8'), 45 | } 46 | ) 47 | else: 48 | with open(os.path.join('.', 'clients', language, 'swagger.json'), 'wb') as swagger_json: 49 | swagger_json.write(swagger_json_content) 50 | 51 | if not offline: 52 | run( 53 | "docker pull 'khorolets/swagger-codegen'" 54 | ) 55 | 56 | run( 57 | "cd './clients/%(language)s' ;" 58 | # Tar the config files to pass them into swagger-codegen docker-container. 59 | "tar -c swagger.json swagger_codegen_config.json" 60 | " | docker run --interactive --rm --entrypoint /bin/sh 'khorolets/swagger-codegen' -c \"" 61 | # Unpack them, generate library code with these files. 62 | " tar -x ;" 63 | " java -jar '/opt/swagger-codegen/modules/swagger-codegen-cli/target/swagger-codegen-cli.jar'" 64 | " generate" 65 | " --input-spec './swagger.json'" 66 | " --lang '%(language)s'" 67 | " --output './dist'" 68 | " --config './swagger_codegen_config.json'" 69 | " --additional-properties 'packageVersion=%(version)s,projectVersion=%(version)s'" 70 | " >&2 ;" 71 | # tar the generated code and return it. 72 | " tar -c dist\"" 73 | # Finally, untar library source into current directory. 74 | " | tar -x" 75 | % { 76 | 'language': language, 77 | 'version': version, 78 | } 79 | ) 80 | -------------------------------------------------------------------------------- /tasks/app/users.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Application Users management related tasks for Invoke. 4 | """ 5 | 6 | from getpass import getpass 7 | 8 | from ._utils import app_context_task 9 | 10 | 11 | @app_context_task 12 | def create_user( 13 | context, 14 | username, 15 | email, 16 | is_internal=False, 17 | is_admin=False, 18 | is_regular_user=True, 19 | is_active=True 20 | ): 21 | """ 22 | Create a new user. 23 | """ 24 | from app.modules.users.models import User 25 | 26 | password = getpass("Enter password: ") 27 | 28 | new_user = User( 29 | username=username, 30 | password=password, 31 | email=email, 32 | is_internal=is_internal, 33 | is_admin=is_admin, 34 | is_regular_user=is_regular_user, 35 | is_active=is_active 36 | ) 37 | 38 | from app.extensions import db 39 | with db.session.begin(): 40 | db.session.add(new_user) 41 | 42 | 43 | 44 | @app_context_task 45 | def create_oauth2_client( 46 | context, 47 | username, 48 | client_id, 49 | client_secret, 50 | default_scopes=None 51 | ): 52 | """ 53 | Create a new OAuth2 Client associated with a given user (username). 54 | """ 55 | from app.modules.users.models import User 56 | from app.modules.auth.models import OAuth2Client 57 | 58 | user = User.query.filter(User.username == username).first() 59 | if not user: 60 | raise Exception("User with username '%s' does not exist." % username) 61 | 62 | if default_scopes is None: 63 | from app.extensions.api import api_v1 64 | default_scopes = list(api_v1.authorizations['oauth2_password']['scopes'].keys()) 65 | 66 | oauth2_client = OAuth2Client( 67 | client_id=client_id, 68 | client_secret=client_secret, 69 | user=user, 70 | default_scopes=default_scopes 71 | ) 72 | 73 | from app.extensions import db 74 | with db.session.begin(): 75 | db.session.add(oauth2_client) 76 | -------------------------------------------------------------------------------- /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 temp_db_instance_helper(db): 28 | def temp_db_instance_manager(instance): 29 | with db.session.begin(): 30 | db.session.add(instance) 31 | 32 | yield instance 33 | 34 | mapper = instance.__class__.__mapper__ 35 | assert len(mapper.primary_key) == 1 36 | instance.__class__.query\ 37 | .filter(mapper.primary_key[0] == mapper.primary_key_from_instance(instance)[0])\ 38 | .delete() 39 | 40 | return temp_db_instance_manager 41 | 42 | 43 | @pytest.fixture(scope='session') 44 | def flask_app_client(flask_app): 45 | flask_app.test_client_class = utils.AutoAuthFlaskClient 46 | flask_app.response_class = utils.JSONResponse 47 | return flask_app.test_client() 48 | 49 | 50 | @pytest.yield_fixture(scope='session') 51 | def regular_user(temp_db_instance_helper): 52 | for _ in temp_db_instance_helper( 53 | utils.generate_user_instance(username='regular_user') 54 | ): 55 | yield _ 56 | 57 | 58 | @pytest.yield_fixture(scope='session') 59 | def readonly_user(temp_db_instance_helper): 60 | for _ in temp_db_instance_helper( 61 | utils.generate_user_instance(username='readonly_user', is_regular_user=False) 62 | ): 63 | yield _ 64 | 65 | 66 | @pytest.yield_fixture(scope='session') 67 | def admin_user(temp_db_instance_helper): 68 | for _ in temp_db_instance_helper( 69 | utils.generate_user_instance(username='admin_user', is_admin=True) 70 | ): 71 | yield _ 72 | 73 | 74 | @pytest.yield_fixture(scope='session') 75 | def internal_user(temp_db_instance_helper): 76 | for _ in temp_db_instance_helper( 77 | utils.generate_user_instance( 78 | username='internal_user', 79 | is_regular_user=False, 80 | is_admin=False, 81 | is_active=True, 82 | is_internal=True 83 | ) 84 | ): 85 | yield _ 86 | -------------------------------------------------------------------------------- /tests/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/tests/extensions/__init__.py -------------------------------------------------------------------------------- /tests/extensions/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/tests/modules/__init__.py -------------------------------------------------------------------------------- /tests/modules/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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, temp_db_instance_helper): 8 | # pylint: disable=invalid-name,unused-argument 9 | from app.modules.auth.models import OAuth2Client 10 | 11 | for _ in temp_db_instance_helper( 12 | OAuth2Client( 13 | user=regular_user, 14 | client_id='regular_user_client', 15 | client_secret='regular_user_secret', 16 | redirect_uris=[], 17 | default_scopes=['auth:read', 'auth:write'] 18 | ) 19 | ): 20 | yield _ 21 | 22 | 23 | @pytest.yield_fixture() 24 | def regular_user_oauth2_token(regular_user_oauth2_client, temp_db_instance_helper): 25 | from app.modules.auth.models import OAuth2Token 26 | 27 | for _ in temp_db_instance_helper( 28 | OAuth2Token( 29 | client=regular_user_oauth2_client, 30 | user=regular_user_oauth2_client.user, 31 | access_token='test_token', 32 | refresh_token='test_refresh_token', 33 | expires=datetime.datetime.now() + datetime.timedelta(seconds=3600), 34 | token_type=OAuth2Token.TokenTypes.Bearer, 35 | scopes=regular_user_oauth2_client.default_scopes 36 | ) 37 | ): 38 | yield _ 39 | -------------------------------------------------------------------------------- /tests/modules/auth/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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/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_oauth2_client): 23 | oauth2_bearer_token = auth.models.OAuth2Token( 24 | client=regular_user_oauth2_client, 25 | user=regular_user_oauth2_client.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_oauth2_client.user 42 | 43 | with db.session.begin(): 44 | db.session.delete(oauth2_bearer_token) 45 | -------------------------------------------------------------------------------- /tests/modules/teams/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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 | TeamMember.query.filter(TeamMember.team == team).delete() 22 | Team.query.filter(Team.id == team.id).delete() 23 | 24 | 25 | @pytest.yield_fixture() 26 | def team_for_nobody(temp_db_instance_helper): 27 | """ 28 | Create a team that not belongs to regural user 29 | """ 30 | from app.modules.teams.models import Team 31 | for _ in temp_db_instance_helper(Team(title="Admin User's team")): 32 | yield _ 33 | -------------------------------------------------------------------------------- /tests/modules/teams/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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 | assert response.status_code == 200 35 | assert 'X-Total-Count' in response.headers 36 | assert int(response.headers['X-Total-Count']) == 1 37 | assert response.content_type == 'application/json' 38 | assert isinstance(response.json, list) 39 | assert set(response.json[0].keys()) >= {'id', 'title'} 40 | if response.json[0]['id'] == team_for_regular_user.id: 41 | assert response.json[0]['title'] == team_for_regular_user.title 42 | 43 | 44 | @pytest.mark.parametrize('auth_scopes', ( 45 | None, 46 | ('teams:write', ), 47 | )) 48 | def test_getting_team_info_by_unauthorized_user_must_fail( 49 | flask_app_client, 50 | regular_user, 51 | team_for_regular_user, 52 | auth_scopes 53 | ): 54 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 55 | response = flask_app_client.get('/api/v1/teams/%d' % team_for_regular_user.id) 56 | 57 | assert response.status_code == 401 58 | assert response.content_type == 'application/json' 59 | assert set(response.json.keys()) >= {'status', 'message'} 60 | 61 | 62 | @pytest.mark.parametrize('auth_scopes', ( 63 | ('teams:read', ), 64 | ('teams:read', 'teams:write', ), 65 | )) 66 | def test_getting_team_info_by_authorized_user( 67 | flask_app_client, 68 | regular_user, 69 | team_for_regular_user, 70 | auth_scopes 71 | ): 72 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 73 | response = flask_app_client.get('/api/v1/teams/%d' % team_for_regular_user.id) 74 | 75 | assert response.status_code == 200 76 | assert response.content_type == 'application/json' 77 | assert set(response.json.keys()) >= {'id', 'title'} 78 | assert response.json['id'] == team_for_regular_user.id 79 | assert response.json['title'] == team_for_regular_user.title 80 | 81 | 82 | @pytest.mark.parametrize('auth_scopes', ( 83 | None, 84 | ('teams:write', ), 85 | )) 86 | def test_getting_list_of_team_members_by_unauthorized_user_must_fail( 87 | flask_app_client, 88 | regular_user, 89 | team_for_regular_user, 90 | auth_scopes 91 | ): 92 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 93 | response = flask_app_client.get('/api/v1/teams/%d/members/' % team_for_regular_user.id) 94 | 95 | assert response.status_code == 401 96 | assert response.content_type == 'application/json' 97 | assert set(response.json.keys()) >= {'status', 'message'} 98 | 99 | 100 | @pytest.mark.parametrize('auth_scopes', ( 101 | ('teams:read', ), 102 | ('teams:read', 'teams:write', ), 103 | )) 104 | def test_getting_list_of_team_members_by_authorized_user( 105 | flask_app_client, 106 | regular_user, 107 | team_for_regular_user, 108 | auth_scopes 109 | ): 110 | with flask_app_client.login(regular_user, auth_scopes=auth_scopes): 111 | response = flask_app_client.get('/api/v1/teams/%d/members/' % team_for_regular_user.id) 112 | 113 | assert response.status_code == 200 114 | assert response.content_type == 'application/json' 115 | assert isinstance(response.json, list) 116 | assert set(response.json[0].keys()) >= {'team', 'user', 'is_leader'} 117 | assert set(member['team']['id'] for member in response.json) == {team_for_regular_user.id} 118 | assert regular_user.id in set(member['user']['id'] for member in response.json) 119 | -------------------------------------------------------------------------------- /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/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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 = models.User.password.property.columns[0].type.context 23 | # NOTE: This is used here to forcefully resolve the LazyCryptContext 24 | password_field_context.context_kwds 25 | password_field_context._config._init_scheme_list(('plaintext', )) 26 | password_field_context._config._init_records() 27 | password_field_context._config._init_default_schemes() 28 | yield 29 | password_field_context._config._init_scheme_list(('bcrypt', )) 30 | password_field_context._config._init_records() 31 | password_field_context._config._init_default_schemes() 32 | 33 | @pytest.fixture() 34 | def user_instance(patch_User_password_scheme): 35 | # pylint: disable=unused-argument,invalid-name 36 | user_id = 1 37 | _user_instance = utils.generate_user_instance(user_id=user_id) 38 | _user_instance.get_id = lambda: user_id 39 | return _user_instance 40 | 41 | @pytest.yield_fixture() 42 | def authenticated_user_instance(flask_app, user_instance): 43 | with flask_app.test_request_context('/'): 44 | login_user(user_instance) 45 | yield current_user 46 | logout_user() 47 | 48 | @pytest.yield_fixture() 49 | def anonymous_user_instance(flask_app): 50 | with flask_app.test_request_context('/'): 51 | yield current_user 52 | -------------------------------------------------------------------------------- /tests/modules/users/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frol/flask-restplus-server-example/53a3a156cc9df414537860ed677bd0cc98dd2271/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(monkeypatch, flask_config_name): 18 | if flask_config_name == 'production': 19 | from config import ProductionConfig 20 | monkeypatch.setattr(ProductionConfig, 'SQLALCHEMY_DATABASE_URI', 'sqlite://') 21 | monkeypatch.setattr(ProductionConfig, 'SECRET_KEY', 'secret') 22 | create_app(flask_config_name=flask_config_name) 23 | 24 | @pytest.mark.parametrize('flask_config_name', ['production', 'development', 'testing']) 25 | def test_create_app_passing_FLASK_CONFIG_env(monkeypatch, flask_config_name): 26 | monkeypatch.setenv('FLASK_CONFIG', flask_config_name) 27 | if flask_config_name == 'production': 28 | from config import ProductionConfig 29 | monkeypatch.setattr(ProductionConfig, 'SQLALCHEMY_DATABASE_URI', 'sqlite://') 30 | monkeypatch.setattr(ProductionConfig, 'SECRET_KEY', 'secret') 31 | create_app() 32 | 33 | def test_create_app_with_conflicting_config(monkeypatch): 34 | monkeypatch.setenv('FLASK_CONFIG', 'production') 35 | with pytest.raises(AssertionError): 36 | create_app('development') 37 | 38 | def test_create_app_with_non_existing_config(): 39 | with pytest.raises(KeyError): 40 | create_app('non-existing-config') 41 | 42 | def test_create_app_with_broken_import_config(): 43 | CONFIG_NAME_MAPPER['broken-import-config'] = 'broken-import-config' 44 | with pytest.raises(ImportError): 45 | create_app('broken-import-config') 46 | del CONFIG_NAME_MAPPER['broken-import-config'] 47 | -------------------------------------------------------------------------------- /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 OAuth2Client, OAuth2Token 43 | 44 | oauth2_client = OAuth2Client( 45 | client_id='OAUTH2_%s' % self._user.username, 46 | client_secret='SECRET', 47 | user=self._user, 48 | default_scopes=[], 49 | ) 50 | 51 | oauth2_bearer_token = OAuth2Token( 52 | client=oauth2_client, 53 | user=self._user, 54 | token_type='Bearer', 55 | access_token='test_access_token', 56 | scopes=self._auth_scopes, 57 | expires=datetime.utcnow() + timedelta(days=1), 58 | ) 59 | 60 | with db.session.begin(): 61 | db.session.add(oauth2_bearer_token) 62 | 63 | extra_headers = ( 64 | ( 65 | 'Authorization', 66 | '{token.token_type} {token.access_token}'.format(token=oauth2_bearer_token) 67 | ), 68 | ) 69 | if kwargs.get('headers'): 70 | kwargs['headers'] += extra_headers 71 | else: 72 | kwargs['headers'] = extra_headers 73 | 74 | response = super(AutoAuthFlaskClient, self).open(*args, **kwargs) 75 | 76 | if self._user is not None: 77 | with db.session.begin(): 78 | db.session.delete(oauth2_bearer_token) 79 | db.session.delete(oauth2_bearer_token.client) 80 | 81 | return response 82 | 83 | 84 | class JSONResponse(Response): 85 | # pylint: disable=too-many-ancestors 86 | """ 87 | A Response class with extra useful helpers, i.e. ``.json`` property. 88 | """ 89 | 90 | @cached_property 91 | def json(self): 92 | return json.loads(self.get_data(as_text=True)) 93 | 94 | 95 | def generate_user_instance( 96 | user_id=None, 97 | username="username", 98 | password=None, 99 | email=None, 100 | first_name="First Name", 101 | middle_name="Middle Name", 102 | last_name="Last Name", 103 | created=None, 104 | updated=None, 105 | is_active=True, 106 | is_regular_user=True, 107 | is_admin=False, 108 | is_internal=False 109 | ): 110 | """ 111 | Returns: 112 | user_instance (User) - an not committed to DB instance of a User model. 113 | """ 114 | # pylint: disable=too-many-arguments 115 | from app.modules.users.models import User 116 | if password is None: 117 | password = '%s_password' % username 118 | user_instance = User( 119 | id=user_id, 120 | username=username, 121 | first_name=first_name, 122 | middle_name=middle_name, 123 | last_name=last_name, 124 | password=password, 125 | email=email or '%s@email.com' % username, 126 | created=created or datetime.now(), 127 | updated=updated or datetime.now(), 128 | is_active=is_active, 129 | is_regular_user=is_regular_user, 130 | is_admin=is_admin, 131 | is_internal=is_internal 132 | ) 133 | user_instance.password_secret = password 134 | return user_instance 135 | --------------------------------------------------------------------------------