From d341788830726aebc99ce7f9c6f5be40a3719fa0 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Tue, 15 Mar 2022 21:29:41 +0000 Subject: [PATCH 001/113] release: 3.4.5 (#1818) --- CHANGELOG.rst | 8 ++++++++ flask_appbuilder/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a8c73791df..4868a9bf82 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,14 @@ Flask-AppBuilder ChangeLog ========================== +Improvements and Bug fixes on 3.4.5 +----------------------------------- + +- test: Add test for `export-roles --indent`'s argument “duck casting” to int (#1811) [Étienne Boisseau-Sierra] +- fix: next url on login (OAuth, OID, DB) (#1804) [Daniel Vaz Gaspar] +- docs: Update doc i18 to flask_babel (#1792) [Federico Padua] +- feat(cli): allow `export-roles` to be beautified (#1724) [Étienne Boisseau-Sierra] + Improvements and Bug fixes on 3.4.4 ----------------------------------- diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 51fca9d2aa..95dfb1919e 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "3.4.4" +__version__ = "3.4.5" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 From 2e2931f2fc470a4f1756a0c39e0cc04b86818d84 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Mon, 21 Mar 2022 09:02:51 +0000 Subject: [PATCH 002/113] chore: major bumps Flask, Click, PyJWT and flask-jwt-extended (#1817) * chore: major bumps Flask, Click, PyJWT and flask-jwt-extended * remove python 3.6 support * breaking jwt-extended 1 * breaking jwt-extended 2 * breaking jwt-extended 3 * fix test * bump to 4.0.0 RC1 for testing * remove config key AUTH_STRICT_RESPONSE_CODES * bump more dependencies * lint and drop support for python 3.6 * fix python requires * reset version back --- .github/workflows/ci.yml | 4 +-- CONTRIBUTING.rst | 11 +++--- docs/config.rst | 5 --- flask_appbuilder/security/api.py | 4 +-- flask_appbuilder/security/decorators.py | 25 ++++---------- flask_appbuilder/security/manager.py | 12 ++++--- flask_appbuilder/tests/config_api.py | 1 - flask_appbuilder/tests/test_api.py | 15 ++------ flask_appbuilder/tests/test_mvc.py | 13 +------ flask_appbuilder/tests/test_mvc_oauth.py | 10 +++--- requirements-extra.txt | 1 + requirements.txt | 44 +++++++++--------------- setup.py | 16 +++++---- 13 files changed, 58 insertions(+), 103 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da93d34c5a..643b95cd45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9.7] + python-version: [3.7, 3.8, 3.9.7] env: SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://pguser:pguserpassword@127.0.0.1:15432/app @@ -153,7 +153,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [3.6, 3.7] + python-version: [3.7] services: mssql: image: mongo:4.4.1-bionic diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index bb5fdaccc6..2fd15600fa 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -28,9 +28,14 @@ can run a subset of tests targeting only Postgres. $ docker-compose up -d - 2 - Run Postgres tests +.. code-block:: bash + + $ nosetests flask_appbuilder.tests + +You can also use tox + .. code-block:: bash $ tox -e postgres @@ -64,8 +69,7 @@ Using Postgres .. code-block:: bash - $ nosetests -v flask_appbuilder.tests.test_0_fixture - + $ nosetests -v flask_appbuilder.tests.test_A_fixture 4 - Run a single test @@ -73,7 +77,6 @@ Using Postgres $ nosetests -v flask_appbuilder.tests.test_api:APITestCase.test_get_item_dotted_mo_notation - .. note:: If your using SQLite3, the location of the db is: ./flask_appbuilder/tests/app.db diff --git a/docs/config.rst b/docs/config.rst index ca109de25d..cd63be2d01 100755 --- a/docs/config.rst +++ b/docs/config.rst @@ -202,11 +202,6 @@ Use config.py to configure the following parameters. By default it will use SQLL | AUTH_ROLE_PUBLIC | Special Role that holds the public | No | | | permissions, no authentication needed. | | +----------------------------------------+--------------------------------------------+-----------+ -| AUTH_STRICT_RESPONSE_CODES | When True, protected endpoints will return | No | -| | HTTP 403 instead of 401. This option will | | -| | be removed and default to True on the next | | -| | major release. defaults to False | | -+----------------------------------------+--------------------------------------------+-----------+ | AUTH_API_LOGIN_ALLOW_MULTIPLE_PROVIDERS| Allow REST API login with alternative auth | No | | True|False | providers (default False) | | +----------------------------------------+--------------------------------------------+-----------+ diff --git a/flask_appbuilder/security/api.py b/flask_appbuilder/security/api.py index 014d74c46a..59c893c9c7 100644 --- a/flask_appbuilder/security/api.py +++ b/flask_appbuilder/security/api.py @@ -13,7 +13,7 @@ create_access_token, create_refresh_token, get_jwt_identity, - jwt_refresh_token_required, + jwt_required, ) from marshmallow import ValidationError @@ -115,7 +115,7 @@ def login(self) -> Response: return self.response(200, **resp) @expose("/refresh", methods=["POST"]) - @jwt_refresh_token_required + @jwt_required(refresh=True) @safe def refresh(self) -> Response: """ diff --git a/flask_appbuilder/security/decorators.py b/flask_appbuilder/security/decorators.py index 0091d74773..394423d06d 100644 --- a/flask_appbuilder/security/decorators.py +++ b/flask_appbuilder/security/decorators.py @@ -1,6 +1,5 @@ import functools import logging -from typing import TYPE_CHECKING from flask import ( current_app, @@ -24,22 +23,8 @@ log = logging.getLogger(__name__) -if TYPE_CHECKING: - from flask_appbuilder.api import BaseApi - -def response_unauthorized(base_class: "BaseApi") -> Response: - if current_app.config.get("AUTH_STRICT_RESPONSE_CODES", False): - return base_class.response_403() - return base_class.response_401() - - -def response_unauthorized_mvc() -> Response: - status_code = 401 - if current_app.appbuilder.sm.current_user and current_app.config.get( - "AUTH_STRICT_RESPONSE_CODES", False - ): - status_code = 403 +def response_unauthorized_mvc(status_code: int) -> Response: response = make_response( jsonify({"message": str(FLAMSG_ERR_SEC_ACCESS_DENIED), "severity": "danger"}), status_code, @@ -88,7 +73,7 @@ def wraps(self, *args, **kwargs): class_permission_name = self.class_permission_name # Check if permission is allowed on the class if permission_str not in self.base_permissions: - return response_unauthorized(self) + return self.response_403() # Check if the resource is public if current_app.appbuilder.sm.is_item_public( permission_str, class_permission_name @@ -116,7 +101,7 @@ def wraps(self, *args, **kwargs): permission_str, class_permission_name ) ) - return response_unauthorized(self) + return self.response_403() f._permission_name = permission_str return functools.update_wrapper(wraps, f) @@ -194,7 +179,9 @@ def wraps(self, *args, **kwargs): permission_str, self.__class__.__name__ ) ) - return response_unauthorized_mvc() + if not current_user.is_authenticated: + return response_unauthorized_mvc(401) + return response_unauthorized_mvc(403) f._permission_name = permission_str return functools.update_wrapper(wraps, f) diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index bd173a6813..0f6c14cbf5 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -304,7 +304,7 @@ def create_jwt_manager(self, app) -> JWTManager: """ jwt_manager = JWTManager() jwt_manager.init_app(app) - jwt_manager.user_loader_callback_loader(self.load_user_jwt) + jwt_manager.user_lookup_loader(self.load_user_jwt) return jwt_manager def create_builtin_roles(self): @@ -871,7 +871,8 @@ def auth_user_db(self, username, password): ) log.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(username)) # Balance failure and success - self.noop_user_update(first_user) + if first_user: + self.noop_user_update(first_user) return None elif check_password_hash(user.password, password): self.update_user_auth_stat(user, True) @@ -1499,7 +1500,7 @@ def _get_user_permission_view_menus( result.update(pvms_names) return result - def has_access(self, permission_name, view_name): + def has_access(self, permission_name: str, view_name: str) -> bool: """ Check if current user or public has access to view or menu """ @@ -2036,8 +2037,9 @@ def import_roles(self, path: str) -> None: def load_user(self, pk): return self.get_user_by_id(int(pk)) - def load_user_jwt(self, pk): - user = self.load_user(pk) + def load_user_jwt(self, _jwt_header, jwt_data): + identity = jwt_data["sub"] + user = self.load_user(identity) # Set flask g.user to JWT user, we can't do it on before request g.user = user return user diff --git a/flask_appbuilder/tests/config_api.py b/flask_appbuilder/tests/config_api.py index e271baf68c..a22ada4225 100644 --- a/flask_appbuilder/tests/config_api.py +++ b/flask_appbuilder/tests/config_api.py @@ -6,7 +6,6 @@ "SQLALCHEMY_DATABASE_URI" ) or "sqlite:///" + os.path.join(basedir, "app.db") -AUTH_STRICT_RESPONSE_CODES = False SECRET_KEY = "thisismyscretkey" SQLALCHEMY_TRACK_MODIFICATIONS = False WTF_CSRF_ENABLED = False diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index 5aef9c1644..aff9795b98 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -627,14 +627,9 @@ def test_auth_authorization(self): pk = 1 uri = f"api/v1/model1apirestrictedpermissions/{pk}" - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = True rv = self.auth_client_delete(client, token, uri) self.assertEqual(rv.status_code, 403) - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = False - rv = self.auth_client_delete(client, token, uri) - self.assertEqual(rv.status_code, 401) - # Test unauthorized POST item = dict( field_string="test{}".format(MODEL1_DATA_SIZE + 1), @@ -644,12 +639,8 @@ def test_auth_authorization(self): ) uri = "api/v1/model1apirestrictedpermissions/" - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = True rv = self.auth_client_post(client, token, uri, item) self.assertEqual(rv.status_code, 403) - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = False - rv = self.auth_client_post(client, token, uri, item) - self.assertEqual(rv.status_code, 401) # Test authorized GET uri = f"api/v1/model1apirestrictedpermissions/{pk}" @@ -666,7 +657,7 @@ def test_auth_builtin_roles(self): pk = 1 uri = "api/v1/model1api/{}".format(pk) rv = self.auth_client_delete(client, token, uri) - self.assertEqual(rv.status_code, 401) + self.assertEqual(rv.status_code, 403) # Test unauthorized POST item = dict( @@ -677,7 +668,7 @@ def test_auth_builtin_roles(self): ) uri = "api/v1/model1api/" rv = self.auth_client_post(client, token, uri, item) - self.assertEqual(rv.status_code, 401) + self.assertEqual(rv.status_code, 403) # Test authorized GET uri = "api/v1/model1api/1" @@ -2804,7 +2795,7 @@ class Model2PermOverride2(ModelRestApi): self.assertEqual(rv.status_code, 200) uri = "api/v1/model2permoverride2/1" rv = self.auth_client_delete(client, token, uri) - self.assertEqual(rv.status_code, 401) + self.assertEqual(rv.status_code, 403) # Revert test data self.appbuilder.get_session.delete( diff --git a/flask_appbuilder/tests/test_mvc.py b/flask_appbuilder/tests/test_mvc.py index 949373d950..fc63815ae6 100644 --- a/flask_appbuilder/tests/test_mvc.py +++ b/flask_appbuilder/tests/test_mvc.py @@ -1342,10 +1342,7 @@ def test_api_unauthenticated(self): Testing unauthenticated access to MVC API """ client = self.app.test_client() - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = True - rv = client.get("/model1formattedview/api/read") - self.assertEqual(rv.status_code, 401) - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = False + self.browser_logout(client) rv = client.get("/model1formattedview/api/read") self.assertEqual(rv.status_code, 401) @@ -1355,7 +1352,6 @@ def test_api_unauthorized(self): """ client = self.app.test_client() self.browser_login(client, USERNAME_READONLY, PASSWORD_READONLY) - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = True rv = client.post( "/model1view/api/create", @@ -1363,13 +1359,6 @@ def test_api_unauthorized(self): follow_redirects=True, ) self.assertEqual(rv.status_code, 403) - self.app.config["AUTH_STRICT_RESPONSE_CODES"] = False - rv = client.post( - "/model1view/api/create", - data=dict(field_string="zzz"), - follow_redirects=True, - ) - self.assertEqual(rv.status_code, 401) def test_api_create(self): """ diff --git a/flask_appbuilder/tests/test_mvc_oauth.py b/flask_appbuilder/tests/test_mvc_oauth.py index 34014784e2..4620226839 100644 --- a/flask_appbuilder/tests/test_mvc_oauth.py +++ b/flask_appbuilder/tests/test_mvc_oauth.py @@ -47,7 +47,7 @@ def test_oauth_login(self): raw_state = {} state = jwt.encode(raw_state, self.app.config["SECRET_KEY"], algorithm="HS256") - response = client.get(f"/oauth-authorized/google?state={state.decode('utf-8')}") + response = client.get(f"/oauth-authorized/google?state={state}") self.assertEqual(response.location, "http://localhost/") def test_oauth_login_unknown_provider(self): @@ -61,9 +61,7 @@ def test_oauth_login_unknown_provider(self): raw_state = {} state = jwt.encode(raw_state, self.app.config["SECRET_KEY"], algorithm="HS256") - response = client.get( - f"/oauth-authorized/unknown_provider?state={state.decode('utf-8')}" - ) + response = client.get(f"/oauth-authorized/unknown_provider?state={state}") self.assertEqual(response.location, "http://localhost/login/") def test_oauth_login_next(self): @@ -77,7 +75,7 @@ def test_oauth_login_next(self): raw_state = {"next": ["http://localhost/users/list/"]} state = jwt.encode(raw_state, self.app.config["SECRET_KEY"], algorithm="HS256") - response = client.get(f"/oauth-authorized/google?state={state.decode('utf-8')}") + response = client.get(f"/oauth-authorized/google?state={state}") self.assertEqual(response.location, "http://localhost/users/list/") def test_oauth_login_next_check(self): @@ -91,5 +89,5 @@ def test_oauth_login_next_check(self): raw_state = {"next": ["http://www.google.com"]} state = jwt.encode(raw_state, self.app.config["SECRET_KEY"], algorithm="HS256") - response = client.get(f"/oauth-authorized/google?state={state.decode('utf-8')}") + response = client.get(f"/oauth-authorized/google?state={state}") self.assertEqual(response.location, "http://localhost/") diff --git a/requirements-extra.txt b/requirements-extra.txt index ca28e8b63e..0a9996fdc9 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -9,3 +9,4 @@ pyodbc==4.0.30 requests==2.26.0 Authlib==0.15.4 python-ldap==3.3.1 +flask-openid==1.3.0 diff --git a/requirements.txt b/requirements.txt index 3df2b6e9cc..de59a778cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,67 +1,62 @@ # -# This file is autogenerated by pip-compile with python 3.7 +# This file is autogenerated by pip-compile with python 3.8 # To update, run: # # pip-compile # apispec[yaml]==3.3.2 # via Flask-AppBuilder (setup.py) -attrs==21.2.0 +attrs==21.4.0 # via jsonschema babel==2.9.1 # via flask-babel -click==7.1.2 +click==8.0.4 # via # Flask-AppBuilder (setup.py) # flask colorama==0.4.4 # via Flask-AppBuilder (setup.py) -defusedxml==0.7.1 - # via python3-openid -dnspython==2.1.0 +dnspython==2.2.1 # via email-validator email-validator==1.1.3 # via Flask-AppBuilder (setup.py) -flask==1.1.4 +flask==2.0.3 # via # Flask-AppBuilder (setup.py) # flask-babel # flask-jwt-extended # flask-login - # flask-openid # flask-sqlalchemy # flask-wtf flask-babel==2.0.0 # via Flask-AppBuilder (setup.py) -flask-jwt-extended==3.25.1 +flask-jwt-extended==4.3.1 # via Flask-AppBuilder (setup.py) flask-login==0.4.1 # via Flask-AppBuilder (setup.py) -flask-openid==1.3.0 - # via Flask-AppBuilder (setup.py) flask-sqlalchemy==2.5.1 # via Flask-AppBuilder (setup.py) flask-wtf==0.14.3 # via Flask-AppBuilder (setup.py) -idna==3.2 +greenlet==1.1.2 + # via sqlalchemy +idna==3.3 # via email-validator -importlib-metadata==4.8.1 - # via jsonschema -itsdangerous==1.1.0 +itsdangerous==2.1.1 # via # flask # flask-wtf -jinja2==2.11.3 +jinja2==3.0.3 # via # flask # flask-babel jsonschema==3.2.0 # via Flask-AppBuilder (setup.py) -markupsafe==2.0.1 +markupsafe==2.1.1 # via # jinja2 # wtforms -marshmallow==3.13.0 +marshmallow==3.15.0 # via # Flask-AppBuilder (setup.py) # marshmallow-enum @@ -72,16 +67,14 @@ marshmallow-sqlalchemy==0.26.1 # via Flask-AppBuilder (setup.py) prison==0.2.1 # via Flask-AppBuilder (setup.py) -pyjwt==1.7.1 +pyjwt==2.3.0 # via # Flask-AppBuilder (setup.py) # flask-jwt-extended -pyrsistent==0.18.0 +pyrsistent==0.18.1 # via jsonschema python-dateutil==2.8.2 # via Flask-AppBuilder (setup.py) -python3-openid==3.2.0 - # via flask-openid pytz==2021.1 # via # babel @@ -90,7 +83,6 @@ pyyaml==5.4.1 # via apispec six==1.16.0 # via - # flask-jwt-extended # jsonschema # prison # python-dateutil @@ -103,9 +95,7 @@ sqlalchemy==1.4.29 # sqlalchemy-utils sqlalchemy-utils==0.37.8 # via Flask-AppBuilder (setup.py) -typing-extensions==3.10.0.2 - # via importlib-metadata -werkzeug==1.0.1 +werkzeug==2.0.3 # via # flask # flask-jwt-extended @@ -113,8 +103,6 @@ wtforms==2.3.3 # via # Flask-AppBuilder (setup.py) # flask-wtf -zipp==3.5.0 - # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/setup.py b/setup.py index f94df8aff5..9993dc5db3 100644 --- a/setup.py +++ b/setup.py @@ -47,22 +47,21 @@ def desc(): install_requires=[ "apispec[yaml]>=3.3, <4", "colorama>=0.3.9, <1", - "click>=6.7, <9", + "click>=8, <9", "email_validator>=1.0.5, <2", - "Flask>=0.12, <2", + "Flask>=2, <3", "Flask-Babel>=1, <3", "Flask-Login>=0.3, <0.5", - "Flask-OpenID>=1.2.5, <2", "Flask-SQLAlchemy>=2.4, <3", "Flask-WTF>=0.14.2, <0.15.0", - "Flask-JWT-Extended>=3.18, <4", + "Flask-JWT-Extended>=4.0.0, <5.0.0", "jsonschema>=3, <5", "marshmallow>=3, <4", "marshmallow-enum>=1.5.1, <2", "marshmallow-sqlalchemy>=0.22.0, <0.27.0", "python-dateutil>=2.3, <3", "prison>=0.2.1, <1.0.0", - "PyJWT>=1.7.1, <2.0.0", + "PyJWT>=2.0.0, <3.0.0", # Cautious cap "SQLAlchemy<1.5", "sqlalchemy-utils>=0.32.21, <1", @@ -71,6 +70,7 @@ def desc(): extras_require={ "jmespath": ["jmespath>=0.9.5"], "oauth": ["Authlib>=0.14, <1.0.0"], + "openid": ["Flask-OpenID>=1.2.5, <2"], }, tests_require=["nose>=1.0", "mockldap>=0.3.0"], classifiers=[ @@ -80,10 +80,12 @@ def desc(): "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Topic :: Software Development :: Libraries :: Python Modules", ], - python_requires="~=3.6", + python_requires="~=3.7", test_suite="nose.collector", ) From a86835f97716efa5002b498bde0c5a17f702c9dc Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Mon, 21 Mar 2022 11:03:36 +0000 Subject: [PATCH 003/113] release: 4.0.0 (#1819) * release: 4.0.0 * docs --- CHANGELOG.rst | 6 +++ README.rst | 46 ------------------- docs/breaking.rst | 86 ++++++++++++++++++++++++++++++++++++ docs/index.rst | 2 +- flask_appbuilder/__init__.py | 2 +- 5 files changed, 94 insertions(+), 48 deletions(-) create mode 100644 docs/breaking.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4868a9bf82..50bb1a61f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Flask-AppBuilder ChangeLog ========================== +Improvements and Bug fixes on 4.0.0 +----------------------------------- + +- chore: major bumps Flask, Click, PyJWT and flask-jwt-extended (#1817) [Daniel Vaz Gaspar] + [Breaking changes] + Improvements and Bug fixes on 3.4.5 ----------------------------------- diff --git a/README.rst b/README.rst index 7527083749..50dcc4bf95 100644 --- a/README.rst +++ b/README.rst @@ -40,52 +40,6 @@ Change Log `Versions `_ for further detail on what changed. -BREAKING CHANGE on 3.0.0 (OAuth) - -Major version 3, changed it's **OAuth** dependency from flask-oauth to authlib, due to this OAuth configuration -changed: - -Before: - -.. code-block:: - - OAUTH_PROVIDERS = [ - {'name':'google', 'icon':'fa-google', 'token_key':'access_token', - 'remote_app': { - 'consumer_key':'GOOGLE KEY', - 'consumer_secret':'GOOGLE SECRET', - 'base_url':'https://www.googleapis.com/oauth2/v2/', - 'request_token_params':{ - 'scope': 'email profile' - }, - 'request_token_url':None, - 'access_token_url':'https://accounts.google.com/o/oauth2/token', - 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} - } - ] - -Now: - -.. code-block:: - - OAUTH_PROVIDERS = [ - {'name':'google', 'icon':'fa-google', 'token_key':'access_token', - 'remote_app': { - 'client_id':'GOOGLE KEY', - 'client_secret':'GOOGLE SECRET', - 'api_base_url':'https://www.googleapis.com/oauth2/v2/', - 'client_kwargs':{ - 'scope': 'email profile' - }, - 'request_token_url':None, - 'access_token_url':'https://accounts.google.com/o/oauth2/token', - 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} - } - ] - -Also make sure you change your dependency for flask-oauth to `authlib `_ - - Fixes, Bugs and contributions ----------------------------- diff --git a/docs/breaking.rst b/docs/breaking.rst new file mode 100644 index 0000000000..f20e662630 --- /dev/null +++ b/docs/breaking.rst @@ -0,0 +1,86 @@ +BREAKING CHANGES +================ + +Version 4.0.0 +------------- + +- Drops python 3.6 support +- Removed config key `AUTH_STRICT_RESPONSE_CODES`, it's always strict now. +- Removes `Flask-OpenID` dependency (you can install it has an extra dependency `pip install flask-appbuilder[openid]`) +- Major version bumps on following packages + +**Flask from 1.X to 2.X** + +Breaking changes: https://flask.palletsprojects.com/en/2.0.x/changes/#version-2-0-0 + +**flask-jwt-extended 3.X to 4.X:** + +Breaking changes: https://flask-jwt-extended.readthedocs.io/en/stable/v4_upgrade_guide/ + +**Jinja2 2.X to 3.X** + +Breaking changes: https://jinja.palletsprojects.com/en/3.0.x/changes/#version-3-0-0 + +**Werkzeug 1.X to 2.X** + +https://werkzeug.palletsprojects.com/en/2.0.x/changes/#version-2-0-0 + +The following packages are probably not impactful to you: + +**pyJWT 1.X to 2.X:** + +Breaking changes: https://pyjwt.readthedocs.io/en/stable/changelog.html#v2-0-0 + +**Click 7.X to 8.X:** + +Breaking changes: https://click.palletsprojects.com/en/8.0.x/changes/#version-8-0-0 + +**itsdangerous 1.X to 2.X** + +Breaking changes: https://github.com/pallets/itsdangerous/blob/main/CHANGES.rst#version-200 + +Version 3.0.0 (OAuth) +--------------------- + +Major version 3, changed it's **OAuth** dependency from flask-oauth to authlib, due to this OAuth configuration +changed: + +Before: + +.. code-block:: + + OAUTH_PROVIDERS = [ + {'name':'google', 'icon':'fa-google', 'token_key':'access_token', + 'remote_app': { + 'consumer_key':'GOOGLE KEY', + 'consumer_secret':'GOOGLE SECRET', + 'base_url':'https://www.googleapis.com/oauth2/v2/', + 'request_token_params':{ + 'scope': 'email profile' + }, + 'request_token_url':None, + 'access_token_url':'https://accounts.google.com/o/oauth2/token', + 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} + } + ] + +Now: + +.. code-block:: + + OAUTH_PROVIDERS = [ + {'name':'google', 'icon':'fa-google', 'token_key':'access_token', + 'remote_app': { + 'client_id':'GOOGLE KEY', + 'client_secret':'GOOGLE SECRET', + 'api_base_url':'https://www.googleapis.com/oauth2/v2/', + 'client_kwargs':{ + 'scope': 'email profile' + }, + 'request_token_url':None, + 'access_token_url':'https://accounts.google.com/o/oauth2/token', + 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} + } + ] + +Also make sure you change your dependency for flask-oauth to `authlib `_ diff --git a/docs/index.rst b/docs/index.rst index 453c397485..4b2c6699fe 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -70,7 +70,7 @@ Contents: diagrams api versionmigration - + breaking Indices and tables ================== diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 95dfb1919e..45ed0e6f09 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "3.4.5" +__version__ = "4.0.0" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 From f6f66fc1bcc0163a213e4a2e6f960e91082d201f Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Mon, 21 Mar 2022 11:40:15 +0000 Subject: [PATCH 004/113] fix: doc requirements (#1820) --- rtd_requirements.txt | 129 +++++++++++++++++++++++++++++++++---------- 1 file changed, 99 insertions(+), 30 deletions(-) diff --git a/rtd_requirements.txt b/rtd_requirements.txt index bb1e754914..e7901b763e 100644 --- a/rtd_requirements.txt +++ b/rtd_requirements.txt @@ -1,36 +1,105 @@ funcparserlib==1.0.0.a0 # required by: https://github.com/vlasovskikh/funcparserlib/issues/70 sphinxcontrib-blockdiag>=1.5.0 sphinx-rtd-theme>=0.2.4 -apispec[yaml]==3.3.0 -attrs==19.1.0 # via jsonschema -babel==2.6.0 # via flask-babel -click==7.0 -colorama==0.4.1 -defusedxml==0.5.0 # via python3-openid -flask-babel==1.0.0 -flask-jwt-extended==3.18.0 +apispec[yaml]==3.3.2 + # via Flask-AppBuilder (setup.py) +attrs==21.4.0 + # via jsonschema +babel==2.9.1 + # via flask-babel +click==8.0.4 + # via + # Flask-AppBuilder (setup.py) + # flask +colorama==0.4.4 + # via Flask-AppBuilder (setup.py) +dnspython==2.2.1 + # via email-validator +email-validator==1.1.3 + # via Flask-AppBuilder (setup.py) +flask==2.0.3 + # via + # Flask-AppBuilder (setup.py) + # flask-babel + # flask-jwt-extended + # flask-login + # flask-sqlalchemy + # flask-wtf +flask-babel==2.0.0 + # via Flask-AppBuilder (setup.py) +flask-jwt-extended==4.3.1 + # via Flask-AppBuilder (setup.py) flask-login==0.4.1 -flask-openid==1.3.0 -flask-sqlalchemy==2.4.0 -flask-wtf==0.14.2 -flask==1.1.1 -itsdangerous==1.1.0 # via flask -jinja2==2.10.1 # via flask, flask-babel -jsonschema==3.0.1 -markupsafe==1.1.1 # via jinja2 + # via Flask-AppBuilder (setup.py) +flask-sqlalchemy==2.5.1 + # via Flask-AppBuilder (setup.py) +flask-wtf==0.14.3 + # via Flask-AppBuilder (setup.py) +greenlet==1.1.2 + # via sqlalchemy +idna==3.3 + # via email-validator +itsdangerous==2.1.1 + # via + # flask + # flask-wtf +jinja2==3.0.3 + # via + # flask + # flask-babel +jsonschema==3.2.0 + # via Flask-AppBuilder (setup.py) +markupsafe==2.1.1 + # via + # jinja2 + # wtforms +marshmallow==3.15.0 + # via + # Flask-AppBuilder (setup.py) + # marshmallow-enum + # marshmallow-sqlalchemy marshmallow-enum==1.5.1 -marshmallow-sqlalchemy==0.23.0 -marshmallow==3.5.1 -prison==0.1.3 -pyjwt==1.7.1 -pyrsistent==0.14.11 # via jsonschema -python-dateutil==2.8.0 -python3-openid==3.1.0 # via flask-openid -pytz==2018.9 # via babel, flask-babel -pyyaml==5.1 # via apispec -six==1.12.0 # via flask-jwt-extended, jsonschema, prison, pyrsistent, python-dateutil, sqlalchemy-utils -sqlalchemy-utils==0.33.9 -sqlalchemy==1.3.1 # via flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils -werkzeug==0.15.5 # via flask, flask-jwt-extended -wtforms==2.2.1 # via flask-wtf + # via Flask-AppBuilder (setup.py) +marshmallow-sqlalchemy==0.26.1 + # via Flask-AppBuilder (setup.py) +prison==0.2.1 + # via Flask-AppBuilder (setup.py) +pyjwt==2.3.0 + # via + # Flask-AppBuilder (setup.py) + # flask-jwt-extended +pyrsistent==0.18.1 + # via jsonschema +python-dateutil==2.8.2 + # via Flask-AppBuilder (setup.py) +pytz==2021.1 + # via + # babel + # flask-babel +pyyaml==5.4.1 + # via apispec +six==1.16.0 + # via + # jsonschema + # prison + # python-dateutil + # sqlalchemy-utils +sqlalchemy==1.4.29 + # via + # Flask-AppBuilder (setup.py) + # flask-sqlalchemy + # marshmallow-sqlalchemy + # sqlalchemy-utils +sqlalchemy-utils==0.37.8 + # via Flask-AppBuilder (setup.py) +werkzeug==2.0.3 + # via + # flask + # flask-jwt-extended +wtforms==2.3.3 + # via + # Flask-AppBuilder (setup.py) + # flask-wtf +# The following packages are considered to be unsafe in a requirements file: +# setuptools From 5f96c4f833246cbeff7d971b5f3eb0960bb872af Mon Sep 17 00:00:00 2001 From: dkrat7 <9586713+dkrat7@users.noreply.github.com> Date: Fri, 8 Apr 2022 15:29:02 +0200 Subject: [PATCH 005/113] chore: add Slovenian language (#1828) * add Slovenian translation * add slovenian translation 2/2 --- docs/i18n.rst | 3 +- .../translations/sl/LC_MESSAGES/messages.mo | Bin 0 -> 9682 bytes .../translations/sl/LC_MESSAGES/messages.po | 690 ++++++++++++++++++ 3 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 flask_appbuilder/translations/sl/LC_MESSAGES/messages.mo create mode 100644 flask_appbuilder/translations/sl/LC_MESSAGES/messages.po diff --git a/docs/i18n.rst b/docs/i18n.rst index 25207f0765..c60fece17d 100644 --- a/docs/i18n.rst +++ b/docs/i18n.rst @@ -4,7 +4,7 @@ i18n Translations Introduction ------------ -F.A.B. has support for 14 languages (planning for some more): +F.A.B. has support for 15 languages (planning for some more): - Chinese - Dutch - English @@ -15,6 +15,7 @@ F.A.B. has support for 14 languages (planning for some more): - Portuguese - Portuguese Brazil - Russian + - Slovenian - Spanish - Greek - Korean diff --git a/flask_appbuilder/translations/sl/LC_MESSAGES/messages.mo b/flask_appbuilder/translations/sl/LC_MESSAGES/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..e7271d8f8aaeffcb145d10c91e386504d3e75505 GIT binary patch literal 9682 zcma)=dvG1qeaBAs2kH%YonqmpQ2R;Z#;UiGvdrn6gHM|gh2ddwjfj@xPQ2r@YzpF1P=O2StP~HVK-yD2Dyg#(Jp~iVQ@Nsw^ z<5*aP4EsVIV?j9 zPeP6N9J~|$G1R!<5A|Mb^>2sT&qOHCK#hMtt9|gP)o`S>h&)^LFE|gu4F__jp4mIyVsP)Z4 z$!jUpKL$1bQ&97qhO&o0fed9{f{4ca9h7{&8~7&F{{92{eL>CllhA$v${}kmfwGTl zpvKt;wg01m4?xYg3^o4>)cQXOwcjT~`|m-G|79rod=;wy*Pzz%I=mHr6G|=@u-Pl& zDAas+K}2rmp!DXTLA$T*?cymzm z*C9(WPs3Z_m*ETVGUlJ5=6?$1YCX?E?dJt3`+Nm5l{o{o-|s+;{}$A|7vhZ4*OgH3 zZ-J`c1trIWQ130k9k2p5-m_5id1v_Z-xIR-op65+YLb`=R_I)I5I~_)k#w^j6?SDCc&{ zqfqnKp!ED8)OtSwHU5+ETKI=h>-iffd;J!aUcV2uzPF*|b}qtI`9o0a9fKNwCzL$* z1RjLi#|+fC5yZtz4Qjqeq4fSdlpMbXHSae<{lA9#A483MKAV#qE`^%^Dk#0&6u2XB zKh(Nr;BI&f@@GEBkJj@dlpJ1ziYMQOlEV+7?C(O1P4%}z3y(mxr%>-b1|NW5gqr_< zpw@p5n^O5QsQw>-@`u~t2%LkMlz9YhgU`WtK|M_EpGWzf33=&G`_x+WAO`a==?;?K z?>>^5p1VuQpO9t;>h_bDKF3K%NIOW1H_N1tkXA{0MoE%eq(XSkfwz$w zqzTezNqQb3)k!z2Fz{sLrTfQ8l8GKinj~!{Np2mI9-Xy%TBN6Z4j+U!ktA13(z8;+ z6z~-33#8u-b@G!ZLjEYclyn#A-ca}Xz$f9MkndtXWPTIgO8O*ejr250&&d+z*8+tK z>2eaG+-xU(f+X8ML6Q$$PufE|mvj$Fc6^W|dw7V{eLhC!G1BKqn3Z{uw3BolDJ98| z2A{D|I0i8%^U;CEJ40T+qvy9svhUv@$sP$u{vLH4>S>lROYnh^UkLm#yn=K!=`iUt zq)SK_k@Va``Z=F7=Hr1|U`(1LeJa#V1>O($k!qyNNbe@yNXkjSP1;N9K8h{BLpnfO z>{h~ONcWMZNmY`Ws8n2@+c>wAE{R=L6=^#uY*Z=YPE^EcLY0u4i7L(2s@)gm&d$25 z8Rxlgr|v-9C|u^7`z_Y5T@|zF4r0oZ=b7{l*ZpZS_Up=fk`0B_e6f;*zGe4WF z^2&;tXk;#`u3Go8I4|-MGm+rt41FC8*!zsy{)G0%+9o2GCgTq zS=x!KF0+fS!FuWe|8Q%%2yq?+56xL7t*33jtVk{UOOnr^VEkuJrFjgqQu1%U=R zPd(U<8n#Hy0ft2+Wk+wq2Agcyv|Y#ykjE01llk@86KU3@8N0AYG}D1LJ$>$=OI*f? zQ(2Z~=Af5u#sE=5A#JxL^KJ&qa*b+krcY@7Hp-k$QamS^YGm8&q-T=l0;ox<`AOXY(05d+xPx_v;FwDWIv&+6dkfXTM6a#gq<5y*qlQ!IKDX~Lnwcd6aJt) zIyQ&3`sGObDXipy+E-FriCS@iJ_!fdS-zDu;aDw4>}c4^a+I-6DM@{+@lssSrC83K z!;)4o%!}BNu{OH)BJ9HY==Y-pQox-Qir=Z592D>9 z#gwyq(}Sr>$LU6%(t~6t9Gg+Ag+G;HrJB0Z)hf%56T-I>Y7`yJY?jK2&742kFa+6d zY2!$LgRv(lyfm2&)f^V6FfC)H^I^1SY~75Fa(g&RRvEDAnv3X>=r-qaM_lhJYh%$K zP%JF#nyWog7$4{7@h3Cyq73cLyGmP8i;RvXyWmZ3ewo8gKbylrS=ra-W_~SxXt&C_E zXRbP8w8`?|@wq{Hou&kxPc49~evtO156iLv8$Y;t4jtOth)2?F>CU0qMw`pY@B#LhkK3f> zi}~*DySVqNW%rik+HIqwyLWH1d+eyCOIaMFxUFBj#Xj^I1z!>*yj(}$<} zd)_uOI;8UoGaf#=+G2eLH;|iK4Q?2_{MkuZ+1)Nq4Dams%c3Md;j-bWq>@(Uf_CR( zTnv@hD(i#l0Sb9}PY_4QJTYmv>OGpE`~R8}SQi|JjBYUQt8 zjMfs4{On}T-F6Kp4i$=^WceAy~zsM0^kL~&^z1CVfLe!|WGFP*; zc-h8li&?yW8c!G@ly~B_xR&c4S9$_s;H*wNbU$;7?2%}V?eAmb(ZJTsJngI$DfCwq z>iBBtx0-tzaXryVOUG84w5!@`_d6(WdEw5cMTT~qU2=INH4C|0Y%_JB-IBO;5xo?R zTHHx*F!!XbIE!kw6l1rfZAIMIRLf0v{kej@G1{@b=tLQdDl@D&HPb-^y$q_7h<$w? z4eV()t#aWmE#VkZI5z(GY)dGsd#3SD$}hW=t1j~c%J&tBU_cEvwY?7QFz*KICDS;8IIFIblh~jqO))p$C7d^F-0wFu41MM?fd!ME$|?gfQ>X*FrrSP zR)qC0czg5n9Is~W^;crduz^pq)-_+@I+1+t%x9ckKaD}@y4u@XeXF({=9YOBvR#dg zNXu_1KJvHPj>~FJYvB8-PM}TXYiZ3KaUI8O9TrrV1QUEr|1C%hS7)StYq{Tu52-Y- zb8}z~1ceSx=;Me>_==Kw<^83x#~9~95J)Sk=h0RIO$~Er1{W#%gayxLF`}uP<54wX z=V2-?QFvd|+Z+P#*;u!}SMd%3C%h`7>vr!NLp|+utIBYbcDMj4O!ey5qGh~D!wx7l z<$5ZW6M}+|b!}j#>Z=bE(QF4NMk+;24qvVEt;N-(t{8RZR926-^67x+=>^#y`cofF zSZ=HKB8&OL)W?Z`&-wSzU^Q)E9116VUTG@Mj4;c#Q9o4L;iDE4I})?*h9bMqdq-i@ zM+g!j#E|vc4*tRm1hio7w5@R*5K_D^p;WXaO?C~%a8Pp{?A36T9L@OGl&p(asBdCg zXHE^8Wf2b=PCd#KS0fGwXPjsd`jK65+wk9_qFpBh&qZrK^y4UmRE{RA@BSn<2Yt}= zzkIBA!*c5DV=Y8N;=f*mR9$p9M+T0skbS3c)$13T8N`Jg24a(J-1&=IFR0_L_yK$G zw94b9j32U|RtWH&|5u-5K3d7K2;7Y4!_bI58u9(a*2|L`m)3v(>|%=b;hjYID10~d z?Xs)!?p0Z5JZE0{&m9${q$H|U(fY=EEVH3=fWJ<(e%iX>B;su9(90W?x|bR5g3^Z% zTr;h-DYfearGF81;t)h<$|zV0BQ(jiv^UnhsrLfz8u0<+^*b%E(QznY@XyJf4 zz6O7+M?PSNj1LtX&g*WMe)M=JmG^pWc6UE~IBB1>IX0zR)+*XhR>JKF;!&E^@g_Qa->o9itmfrkvZasMnL#5n94m8Hb#1 zI=E7#VS7$t^We$@Cx)5sU&l7x1NuwXJ+Zv*_5Gd;O25N~xx4kANKiT*pzZV(Ok$#_82zVq2Kp7mDXZ@%nX77>@%V1=N?EUj|, 2022. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-04-07 22:16+0200\n" +"PO-Revision-Date: 2022-04-07 22:47+0200\n" +"Language: sl\n" +"Language-Team: sl \n" +"Plural-Forms: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n" +"%100==4 ? 2 : 3);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.9.1\n" +"Last-Translator: dkrat7 \n" +"X-Generator: Poedit 2.3\n" + +#: flask_appbuilder/const.py:128 +msgid "Access is Denied" +msgstr "Dostop zavrnjen" + +#: flask_appbuilder/fields.py:153 flask_appbuilder/fields.py:155 +#: flask_appbuilder/fields.py:213 flask_appbuilder/fields.py:220 +#: flask_appbuilder/fields.py:279 +msgid "Not a valid choice" +msgstr "Izbira ni veljavna" + +#: flask_appbuilder/fieldwidgets.py:154 flask_appbuilder/fieldwidgets.py:173 +msgid "Select Value" +msgstr "Izberite vrednost" + +#: flask_appbuilder/messages.py:9 +#: flask_appbuilder/templates/appbuilder/general/charts/chart.html:8 +#: flask_appbuilder/templates/appbuilder/general/charts/chart_time.html:10 +#: flask_appbuilder/templates/appbuilder/general/charts/jsonchart.html:8 +#: flask_appbuilder/templates/appbuilder/general/lib.html:357 +#: flask_appbuilder/templates/appbuilder/general/model/list.html:8 +msgid "Search" +msgstr "Iskanje" + +#: flask_appbuilder/messages.py:10 +#: flask_appbuilder/templates/appbuilder/general/lib.html:364 +#: flask_appbuilder/templates/appbuilder/general/lib.html:365 +msgid "Back" +msgstr "Nazaj" + +#: flask_appbuilder/messages.py:11 +#: flask_appbuilder/templates/appbuilder/general/lib.html:304 +msgid "Save" +msgstr "Shrani" + +#: flask_appbuilder/messages.py:12 +msgid "This field is required." +msgstr "Polje je obvezno." + +#: flask_appbuilder/messages.py:13 +msgid "Not a valid date value" +msgstr "Datum ni veljaven" + +#: flask_appbuilder/messages.py:14 +#: flask_appbuilder/templates/appbuilder/general/widgets/base_list.html:38 +#: flask_appbuilder/templates/appbuilder/general/widgets/list_carousel.html:50 +#: flask_appbuilder/templates/appbuilder/general/widgets/list_master.html:17 +msgid "No records found" +msgstr "Ni zapisov" + +#: flask_appbuilder/upload.py:160 flask_appbuilder/upload.py:213 +msgid "Invalid file extension" +msgstr "Neveljavna končnica datoteke" + +#: flask_appbuilder/validators.py:52 +msgid "Already exists." +msgstr "Že obstaja." + +#: flask_appbuilder/validators.py:87 +msgid "" +"Must have at least two capital letters, one special character, two digits, " +"three lower case letters and a minimal length of 10." +msgstr "" +"Mora vsebovati vsaj dve tiskani črki, en poseben znak, dve številki, tri " +"male tiskane črke, dolžina pa mora biti vsaj 10 znakov." + +#: flask_appbuilder/charts/views.py:35 +msgid "Group by" +msgstr "Združi" + +#: flask_appbuilder/models/base.py:30 +msgid "Added Row" +msgstr "Dodana vrstica" + +#: flask_appbuilder/models/base.py:31 +msgid "Changed Row" +msgstr "Spremenjena vrstica" + +#: flask_appbuilder/models/base.py:32 +msgid "Deleted Row" +msgstr "Izbrisana vrstica" + +#: flask_appbuilder/models/base.py:33 +msgid "Associated data exists, please delete them first" +msgstr "Povezani podatki že obstajajo. Najprej jih izbrišite." + +#: flask_appbuilder/models/base.py:36 flask_appbuilder/models/base.py:39 +msgid "Integrity error, probably unique constraint" +msgstr "Napaka integritete, verjetno unikaten pogoj" + +#: flask_appbuilder/models/base.py:42 +msgid "General Error" +msgstr "Splošna napaka" + +#: flask_appbuilder/models/group.py:32 +msgid "Count of" +msgstr "Število" + +#: flask_appbuilder/models/group.py:41 +msgid "Sum of" +msgstr "Vsota" + +#: flask_appbuilder/models/group.py:50 +msgid "Avg. of" +msgstr "Povprečje" + +#: flask_appbuilder/models/generic/filters.py:20 +#: flask_appbuilder/models/mongoengine/filters.py:76 +#: flask_appbuilder/models/sqla/filters.py:120 +msgid "Contains" +msgstr "Vsebuje" + +#: flask_appbuilder/models/generic/filters.py:31 +msgid "Contains (insensitive)" +msgstr "Vsebuje (neobčutljivo)" + +#: flask_appbuilder/models/generic/filters.py:38 +#: flask_appbuilder/models/mongoengine/filters.py:84 +#: flask_appbuilder/models/sqla/filters.py:129 +msgid "Not Contains" +msgstr "Ne vsebuje" + +#: flask_appbuilder/models/generic/filters.py:45 +#: flask_appbuilder/models/mongoengine/filters.py:22 +#: flask_appbuilder/models/sqla/filters.py:138 +msgid "Equal to" +msgstr "Enako kot" + +#: flask_appbuilder/models/generic/filters.py:52 +#: flask_appbuilder/models/mongoengine/filters.py:33 +#: flask_appbuilder/models/sqla/filters.py:148 +msgid "Not Equal to" +msgstr "Ni enako kot" + +#: flask_appbuilder/models/generic/filters.py:59 +#: flask_appbuilder/models/mongoengine/filters.py:44 +#: flask_appbuilder/models/sqla/filters.py:159 +msgid "Greater than" +msgstr "Večje kot" + +#: flask_appbuilder/models/generic/filters.py:66 +#: flask_appbuilder/models/mongoengine/filters.py:52 +#: flask_appbuilder/models/sqla/filters.py:173 +msgid "Smaller than" +msgstr "Manjše kot" + +#: flask_appbuilder/models/generic/filters.py:73 +msgid "Start with" +msgstr "Začne se z" + +#: flask_appbuilder/models/mongoengine/filters.py:60 +#: flask_appbuilder/models/sqla/filters.py:84 +msgid "Starts with" +msgstr "Začne se z" + +#: flask_appbuilder/models/mongoengine/filters.py:68 +#: flask_appbuilder/models/sqla/filters.py:93 +msgid "Not Starts with" +msgstr "Ne začne se z" + +#: flask_appbuilder/models/mongoengine/filters.py:92 +#: flask_appbuilder/models/sqla/filters.py:187 +msgid "Relation" +msgstr "Relacija" + +#: flask_appbuilder/models/mongoengine/filters.py:101 +#: flask_appbuilder/models/sqla/filters.py:221 +msgid "Relation as Many" +msgstr "Relacija toliko kot" + +#: flask_appbuilder/models/sqla/filters.py:102 +msgid "Ends with" +msgstr "Konča se z" + +#: flask_appbuilder/models/sqla/filters.py:111 +msgid "Not Ends with" +msgstr "Ne konča se z" + +#: flask_appbuilder/models/sqla/filters.py:204 +msgid "No Relation" +msgstr "Ni relacije" + +#: flask_appbuilder/security/forms.py:25 +msgid "OpenID" +msgstr "OpenID" + +#: flask_appbuilder/security/forms.py:26 flask_appbuilder/security/forms.py:31 +#: flask_appbuilder/security/forms.py:70 flask_appbuilder/security/forms.py:109 +#: flask_appbuilder/security/views.py:156 +#: flask_appbuilder/security/views.py:405 +msgid "User Name" +msgstr "Uporabniško ime" + +#: flask_appbuilder/security/forms.py:27 +msgid "Remember me" +msgstr "Opomni me" + +#: flask_appbuilder/security/forms.py:32 flask_appbuilder/security/forms.py:52 +#: flask_appbuilder/security/forms.py:90 flask_appbuilder/security/views.py:157 +#: flask_appbuilder/security/views.py:304 +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:30 +#: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:27 +msgid "Password" +msgstr "Geslo" + +#: flask_appbuilder/security/forms.py:37 flask_appbuilder/security/forms.py:75 +#: flask_appbuilder/security/forms.py:114 +#: flask_appbuilder/security/views.py:154 +msgid "First Name" +msgstr "Ime" + +#: flask_appbuilder/security/forms.py:40 flask_appbuilder/security/views.py:171 +msgid "Write the user first name or names" +msgstr "Vpišite ime uporabnika" + +#: flask_appbuilder/security/forms.py:43 flask_appbuilder/security/forms.py:80 +#: flask_appbuilder/security/forms.py:119 +#: flask_appbuilder/security/views.py:155 +msgid "Last Name" +msgstr "Priimek" + +#: flask_appbuilder/security/forms.py:46 flask_appbuilder/security/views.py:172 +msgid "Write the user last name" +msgstr "Vpišite priimek uporabnika" + +#: flask_appbuilder/security/forms.py:53 flask_appbuilder/security/forms.py:91 +msgid "" +"Please use a good password policy, this application does not check this for " +"you" +msgstr "Uporabite varno geslo. Ta aplikacija tega ne bo preverila" + +#: flask_appbuilder/security/forms.py:61 flask_appbuilder/security/forms.py:99 +#: flask_appbuilder/security/views.py:310 +msgid "Confirm Password" +msgstr "Potrdite geslo" + +#: flask_appbuilder/security/forms.py:62 flask_appbuilder/security/forms.py:100 +msgid "Please rewrite the password to confirm" +msgstr "Ponovno vpišite geslo za potrditev" + +#: flask_appbuilder/security/forms.py:63 flask_appbuilder/security/forms.py:101 +#: flask_appbuilder/security/views.py:314 +msgid "Passwords must match" +msgstr "Gesli se morata ujemati" + +#: flask_appbuilder/security/forms.py:85 flask_appbuilder/security/forms.py:124 +#: flask_appbuilder/security/views.py:159 +msgid "Email" +msgstr "E-pošta" + +#: flask_appbuilder/security/manager.py:728 +#: flask_appbuilder/security/views.py:147 +msgid "List Users" +msgstr "Seznam uporabnikov" + +#: flask_appbuilder/security/manager.py:731 +msgid "Security" +msgstr "Varnost" + +#: flask_appbuilder/security/manager.py:738 +#: flask_appbuilder/security/views.py:441 +msgid "List Roles" +msgstr "Seznam vlog" + +#: flask_appbuilder/security/manager.py:749 +msgid "User's Statistics" +msgstr "Uporabnikova statistika" + +#: flask_appbuilder/security/manager.py:757 +msgid "User Registrations" +msgstr "Registracija uporabnikov" + +#: flask_appbuilder/security/manager.py:766 +msgid "Base Permissions" +msgstr "Bazna dovoljenja" + +#: flask_appbuilder/security/manager.py:774 +msgid "Views/Menus" +msgstr "Pogledi/Meniji" + +#: flask_appbuilder/security/manager.py:784 +msgid "Permission on Views/Menus" +msgstr "Dovoljenja za Poglede/Menije" + +#: flask_appbuilder/security/registerviews.py:55 +msgid "Account activation" +msgstr "Aktivacija računa" + +#: flask_appbuilder/security/registerviews.py:59 +msgid "Registration sent to your email" +msgstr "Registracija poslana na vaš e-naslov" + +#: flask_appbuilder/security/registerviews.py:61 +msgid "Not possible to register you at the moment, try again later" +msgstr "Trenutno se ni mogoče registrirati. Poskusite kasneje." + +#: flask_appbuilder/security/registerviews.py:65 +msgid "Registration not found" +msgstr "Registracija ni najdena" + +#: flask_appbuilder/security/registerviews.py:67 +msgid "Fill out the registration form" +msgstr "Izpolnite registracijski obrazec" + +#: flask_appbuilder/security/views.py:41 +msgid "List Base Permissions" +msgstr "Seznam baznih dovoljenj" + +#: flask_appbuilder/security/views.py:42 +msgid "Show Base Permission" +msgstr "Prikaži bazna dovoljenja" + +#: flask_appbuilder/security/views.py:43 +msgid "Add Base Permission" +msgstr "Dodaj bazno dovoljenje" + +#: flask_appbuilder/security/views.py:44 +msgid "Edit Base Permission" +msgstr "Uredi bazno dovoljenje" + +#: flask_appbuilder/security/views.py:46 flask_appbuilder/security/views.py:58 +#: flask_appbuilder/security/views.py:450 +msgid "Name" +msgstr "Ime" + +#: flask_appbuilder/security/views.py:53 +msgid "List View Menus" +msgstr "Seznam pogledov menijev" + +#: flask_appbuilder/security/views.py:54 +msgid "Show View Menu" +msgstr "Prikaži meni pogleda" + +#: flask_appbuilder/security/views.py:55 +msgid "Add View Menu" +msgstr "Dodaj meni pogleda" + +#: flask_appbuilder/security/views.py:56 +msgid "Edit View Menu" +msgstr "Uredi meni pogleda" + +#: flask_appbuilder/security/views.py:65 +msgid "List Permissions on Views/Menus" +msgstr "Seznam dovoljenj za Poglede/Menije" + +#: flask_appbuilder/security/views.py:66 +msgid "Show Permission on Views/Menus" +msgstr "Prikaži dovoljenja za Poglede/Menije" + +#: flask_appbuilder/security/views.py:67 +msgid "Add Permission on Views/Menus" +msgstr "Dodaj dovoljenja za Poglede/Menije" + +#: flask_appbuilder/security/views.py:68 +msgid "Edit Permission on Views/Menus" +msgstr "Uredi dovoljenja za Poglede/Menije" + +#: flask_appbuilder/security/views.py:71 +msgid "Permission" +msgstr "Dovoljenje" + +#: flask_appbuilder/security/views.py:72 +msgid "View/Menu" +msgstr "Pogled/Meni" + +#: flask_appbuilder/security/views.py:84 flask_appbuilder/security/views.py:100 +msgid "Reset Password Form" +msgstr "Obrazec za ponastavitev gesla" + +#: flask_appbuilder/security/views.py:86 flask_appbuilder/security/views.py:102 +msgid "Password Changed" +msgstr "Geslo spremenjeno" + +#: flask_appbuilder/security/views.py:112 +msgid "Edit User Information" +msgstr "Uredite informacije o uporabniku" + +#: flask_appbuilder/security/views.py:114 +msgid "User information changed" +msgstr "Uporabnikovi podatki spremenjeni" + +#: flask_appbuilder/security/views.py:148 +msgid "Show User" +msgstr "Prikaži uporabnika" + +#: flask_appbuilder/security/views.py:149 +msgid "Add User" +msgstr "Dodaj uporabnika" + +#: flask_appbuilder/security/views.py:150 +#: flask_appbuilder/security/views.py:248 +msgid "Edit User" +msgstr "Uredi uporabnika" + +#: flask_appbuilder/security/views.py:153 +msgid "Full Name" +msgstr "Celotno ime" + +#: flask_appbuilder/security/views.py:158 +msgid "Is Active?" +msgstr "Aktiven?" + +#: flask_appbuilder/security/views.py:160 +msgid "Role" +msgstr "Vloga" + +#: flask_appbuilder/security/views.py:161 +msgid "Last login" +msgstr "Zadnja prijava" + +#: flask_appbuilder/security/views.py:162 +#: flask_appbuilder/security/views.py:406 +msgid "Login count" +msgstr "Število prijav" + +#: flask_appbuilder/security/views.py:163 +#: flask_appbuilder/security/views.py:407 +msgid "Failed login count" +msgstr "Število neuspešnih prijav" + +#: flask_appbuilder/security/views.py:164 +msgid "Created on" +msgstr "Ustvarjeno" + +#: flask_appbuilder/security/views.py:165 +msgid "Created by" +msgstr "Ustvaril" + +#: flask_appbuilder/security/views.py:166 +msgid "Changed on" +msgstr "Spremenjen" + +#: flask_appbuilder/security/views.py:167 +msgid "Changed by" +msgstr "Spremenil" + +#: flask_appbuilder/security/views.py:173 +msgid "Username valid for authentication on DB or LDAP, unused for OID auth" +msgstr "" +"Uporabniško ime je veljavno za DB ali LDAP avtentikacijo. Ni uporabljeno za " +"OID avtentikacijo" + +#: flask_appbuilder/security/views.py:176 +#: flask_appbuilder/security/views.py:305 +msgid "The user's password for authentication" +msgstr "Uporabnikovo geslo za avtentikacijo" + +#: flask_appbuilder/security/views.py:177 +msgid "It's not a good policy to remove a user, just make it inactive" +msgstr "Izbris uporabnika ni dobra praksa, raje ga deaktivirajte" + +#: flask_appbuilder/security/views.py:180 +msgid "The user's email, this will also be used for OID auth" +msgstr "Uporabnikov e-naslov, uporabljen bo tudi za OID avtentikacijo" + +#: flask_appbuilder/security/views.py:186 +#: flask_appbuilder/security/views.py:311 +msgid "Please rewrite the user's password to confirm" +msgstr "Ponovno vpišite geslo za potrditev" + +#: flask_appbuilder/security/views.py:193 +#: flask_appbuilder/security/views.py:218 +msgid "User info" +msgstr "Informacije o uporabniku" + +#: flask_appbuilder/security/views.py:197 +#: flask_appbuilder/security/views.py:222 +msgid "Personal Info" +msgstr "Osebne informacije" + +#: flask_appbuilder/security/views.py:201 +msgid "Audit Info" +msgstr "Revizijske informacije" + +#: flask_appbuilder/security/views.py:231 +msgid "Your user information" +msgstr "Vaše uporabniške informacije" + +#: flask_appbuilder/security/views.py:373 +msgid "Reset my password" +msgstr "Ponastavi geslo" + +#: flask_appbuilder/security/views.py:384 +msgid "Reset Password" +msgstr "Ponastavi geslo" + +#: flask_appbuilder/security/views.py:403 +msgid "User Statistics" +msgstr "Uporabnikova statistika" + +#: flask_appbuilder/security/views.py:442 +msgid "Show Role" +msgstr "Prikaži vlogo" + +#: flask_appbuilder/security/views.py:443 +msgid "Add Role" +msgstr "Dodaj vlogo" + +#: flask_appbuilder/security/views.py:444 +msgid "Edit Role" +msgstr "Uredi vlogo" + +#: flask_appbuilder/security/views.py:451 +msgid "Permissions" +msgstr "Dovoljenja" + +#: flask_appbuilder/security/views.py:461 +msgid "Copy Role" +msgstr "Kopiraj vlogo" + +#: flask_appbuilder/security/views.py:462 +msgid "Copy the selected roles?" +msgstr "Kopiraj izbrane vloge?" + +#: flask_appbuilder/security/views.py:480 +msgid "List of Registration Requests" +msgstr "Seznam zahtev za registracijo" + +#: flask_appbuilder/security/views.py:481 +msgid "Show Registration" +msgstr "Prikaži registracijo" + +#: flask_appbuilder/security/views.py:490 +msgid "Invalid login. Please try again." +msgstr "Neveljavna prijava. Poskusite znova." + +#: flask_appbuilder/security/views.py:491 +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:46 +#: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:40 +#: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:118 +msgid "Sign In" +msgstr "Prijava" + +#: flask_appbuilder/templates/appbuilder/baselib.html:115 +#: flask_appbuilder/templates/appbuilder/navbar_right.html:37 +msgid "Profile" +msgstr "Profil" + +#: flask_appbuilder/templates/appbuilder/baselib.html:116 +#: flask_appbuilder/templates/appbuilder/navbar_right.html:38 +msgid "Logout" +msgstr "Odjava" + +#: flask_appbuilder/templates/appbuilder/baselib.html:122 +#: flask_appbuilder/templates/appbuilder/navbar_right.html:43 +msgid "Login" +msgstr "Prijava" + +#: flask_appbuilder/templates/appbuilder/general/security/activation.html:5 +#: flask_appbuilder/templates/appbuilder/index.html:4 +msgid "Welcome" +msgstr "Dobrodošli" + +#: flask_appbuilder/templates/appbuilder/general/confirm.html:6 +msgid "User confirmation needed" +msgstr "Potrebna je potrditev s strani uporabnika" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:63 +msgid "Actions" +msgstr "Aktivnosti" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:102 +msgid "Page size" +msgstr "Velikost strani" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:122 +msgid "Order by" +msgstr "Razvrsti" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:324 +msgid "Record Count" +msgstr "Število zapisov" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:373 +msgid "Add a new record" +msgstr "Dodaj zapis" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:374 +msgid "Add" +msgstr "Dodaj" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:381 +msgid "Edit record" +msgstr "Uredi zapis" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:382 +msgid "Edit" +msgstr "Uredi" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:389 +msgid "Show record" +msgstr "Prikaži zapis" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:390 +msgid "Show" +msgstr "Prikaži" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:396 +msgid "Delete record" +msgstr "Izbriši zapis" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:397 +msgid "You sure you want to delete this item?" +msgstr "Ste prepričani, da želite izbrisati ta vnos?" + +#: flask_appbuilder/templates/appbuilder/general/lib.html:398 +msgid "Delete" +msgstr "Izbriši" + +#: flask_appbuilder/templates/appbuilder/general/charts/chart_time.html:17 +msgid "Group by fields" +msgstr "Polja za združevanje" + +#: flask_appbuilder/templates/appbuilder/general/model/edit.html:9 +#: flask_appbuilder/templates/appbuilder/general/model/show.html:9 +msgid "Detail" +msgstr "Podrobnosti" + +#: flask_appbuilder/templates/appbuilder/general/security/activation.html:7 +msgid "Your user is activated you can now proceed to login" +msgstr "Uporabnik je aktiviran. Sedaj se lahko prijavite." + +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:18 +#: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:16 +msgid "Enter your login and password below" +msgstr "Vnesite uporabniško ime in geslo" + +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:20 +#: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:18 +msgid "Username" +msgstr "Uporabniško ime" + +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:49 +#: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:121 +msgid "If you are not already a user, please register" +msgstr "Če še niste uporabnik, se registrirajte" + +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:50 +#: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:122 +msgid "Register" +msgstr "Registracija" + +#: flask_appbuilder/templates/appbuilder/general/security/login_oauth.html:30 +msgid "Sign In with " +msgstr "Prijava z " + +#: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:89 +msgid "Click on your OpenID provider below" +msgstr "Kliknite na ponudnika OpenID spodaj" + +#: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:101 +msgid "Or enter your OpenID here" +msgstr "Ali pa vnesite OpenID tukaj" + +#: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:105 +msgid "Please choose a provider" +msgstr "Izberite ponudnika" + +#: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:107 +msgid "Enter your OpenID Username" +msgstr "Vnesite uporabniško ime za OpenID" + +#: flask_appbuilder/templates/appbuilder/general/security/register_oauth.html:15 +msgid "Sign in using:" +msgstr "Prijavite se z:" + +#: flask_appbuilder/templates/appbuilder/general/widgets/search.html:7 +msgid "Add Filter" +msgstr "Dodaj filter" From 06664bd7fb91838a3561405010876c140632561e Mon Sep 17 00:00:00 2001 From: David Berg <55707664+davidnateberg@users.noreply.github.com> Date: Fri, 8 Apr 2022 09:49:51 -0400 Subject: [PATCH 006/113] docs: updated brackets in OAuth Authentication (#1798) * updated brackets in OAuth Authentication * aligned bracket with 'remote_app' pretty sure thats aligned correctly but sorry if it still isn't hah * Properly aligned brackets, for real this time. * updated security.rst OAUTH_PROVIDERS brackets again Co-authored-by: Daniel Vaz Gaspar --- docs/security.rst | 121 ++++++++++++++++++++++++---------------------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/docs/security.rst b/docs/security.rst index 54cae1bb02..acac362918 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -165,67 +165,74 @@ Specify a list of OAUTH_PROVIDERS in **config.py** that you want to allow for yo # the list of providers which the user can choose from OAUTH_PROVIDERS = [ - {'name':'twitter', 'icon':'fa-twitter', - 'token_key':'oauth_token', - 'remote_app': { - 'client_id':'TWITTER_KEY', - 'client_secret':'TWITTER_SECRET', - 'api_base_url':'https://api.twitter.com/1.1/', - 'request_token_url':'https://api.twitter.com/oauth/request_token', - 'access_token_url':'https://api.twitter.com/oauth/access_token', - 'authorize_url':'https://api.twitter.com/oauth/authenticate'} + { + "name": "twitter", + "icon": "fa-twitter", + "token_key": "oauth_token", + "remote_app": { + "client_id": "TWITTER_KEY", + "client_secret": "TWITTER_SECRET", + "api_base_url": "https://api.twitter.com/1.1/", + "request_token_url": "https://api.twitter.com/oauth/request_token", + "access_token_url": "https://api.twitter.com/oauth/access_token", + "authorize_url": "https://api.twitter.com/oauth/authenticate", + }, }, - {'name':'google', 'icon':'fa-google', - 'token_key':'access_token', - 'remote_app': { - 'client_id':'GOOGLE_KEY', - 'client_secret':'GOOGLE_SECRET', - 'api_base_url':'https://www.googleapis.com/oauth2/v2/', - 'client_kwargs':{ - 'scope': 'email profile' - }, - 'request_token_url':None, - 'access_token_url':'https://accounts.google.com/o/oauth2/token', - 'authorize_url':'https://accounts.google.com/o/oauth2/auth'} + { + "name": "google", + "icon": "fa-google", + "token_key": "access_token", + "remote_app": { + "client_id": "GOOGLE_KEY", + "client_secret": "GOOGLE_SECRET", + "api_base_url": "https://www.googleapis.com/oauth2/v2/", + "client_kwargs": {"scope": "email profile"}, + "request_token_url": None, + "access_token_url": "https://accounts.google.com/o/oauth2/token", + "authorize_url": "https://accounts.google.com/o/oauth2/auth", + }, }, - {'name':'openshift', 'icon':'fa-circle-o', - 'token_key':'access_token', - 'remote_app': { - 'client_id':'system:serviceaccount:mynamespace:mysa', - 'client_secret':'', - 'api_base_url':'https://openshift.default.svc.cluster.local:443', - 'client_kwargs':{ - 'scope': 'user:info' - }, - 'redirect_uri':'https://myapp-mynamespace.apps.', - 'access_token_url':'https://oauth-openshift.apps./oauth/token', - 'authorize_url':'https://oauth-openshift.apps./oauth/authorize', - 'token_endpoint_auth_method':'client_secret_post'} + { + "name": "openshift", + "icon": "fa-circle-o", + "token_key": "access_token", + "remote_app": { + "client_id": "system:serviceaccount:mynamespace:mysa", + "client_secret": "", + "api_base_url": "https://openshift.default.svc.cluster.local:443", + "client_kwargs": {"scope": "user:info"}, + "redirect_uri": "https://myapp-mynamespace.apps.", + "access_token_url": "https://oauth-openshift.apps./oauth/token", + "authorize_url": "https://oauth-openshift.apps./oauth/authorize", + "token_endpoint_auth_method": "client_secret_post", + }, }, - {'name': 'okta', 'icon': 'fa-circle-o', - 'token_key': 'access_token', - 'remote_app': { - 'client_id': 'OKTA_KEY', - 'client_secret': 'OKTA_SECRET', - 'api_base_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/', - 'client_kwargs': { - 'scope': 'openid profile email groups' - }, - 'access_token_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/token', - 'authorize_url': 'https://OKTA_DOMAIN.okta.com/oauth2/v1/authorize', + { + "name": "okta", + "icon": "fa-circle-o", + "token_key": "access_token", + "remote_app": { + "client_id": "OKTA_KEY", + "client_secret": "OKTA_SECRET", + "api_base_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/", + "client_kwargs": {"scope": "openid profile email groups"}, + "access_token_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/token", + "authorize_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/authorize", + }, + }, + { + "name": "aws_cognito", + "icon": "fa-amazon", + "token_key": "access_token", + "remote_app": { + "client_id": "COGNITO_CLIENT_ID", + "client_secret": "COGNITO_CLIENT_SECRET", + "api_base_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/", + "client_kwargs": {"scope": "openid email aws.cognito.signin.user.admin"}, + "access_token_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/token", + "authorize_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/authorize", + }, }, - {'name': 'aws_cognito', 'icon': 'fa-amazon', - 'token_key': 'access_token', - 'remote_app': { - 'client_id': 'COGNITO_CLIENT_ID', - 'client_secret': 'COGNITO_CLIENT_SECRET', - 'api_base_url': 'https://COGNITO_APP.auth.REGION.amazoncognito.com/', - 'client_kwargs': { - 'scope': 'openid email aws.cognito.signin.user.admin' - }, - 'access_token_url': 'https://COGNITO_APP.auth.REGION.amazoncognito.com/token', - 'authorize_url': 'https://COGNITO_APP.auth.REGION.amazoncognito.com/authorize', - } ] This needs a small explanation, you basically have five special keys: From 0a6623da9852220337699175bab86d1453b80a4b Mon Sep 17 00:00:00 2001 From: Mayur Date: Tue, 12 Apr 2022 16:11:36 +0530 Subject: [PATCH 007/113] feat: Add CRUD apis for role, permission, user (#1801) * Add CRUD apis for role, permission, user * black and flake * Add schema for user, encrypt password with pre_add * Add edit model schema * Add edit model schema * complete apis * Flake and Black * Add tests * lint * Add view and permission view apis * Fixed session error * Fixed session error * Fix test * avoid session leak * fix api test * suggestions * black flake * Seperate tests * add nested api for role permissions * add test for default password validator * Fix mysql test * add test for invalid role * add 1 test for invlid payload for role permission, suggestions * Remove mutating apis on permissionApi, suggestions Co-authored-by: Daniel Vaz Gaspar --- .../security/sqla/apis/__init__.py | 7 + .../security/sqla/apis/permission/__init__.py | 1 + .../security/sqla/apis/permission/api.py | 19 + .../apis/permission_view_menu/__init__.py | 1 + .../sqla/apis/permission_view_menu/api.py | 17 + .../security/sqla/apis/role/__init__.py | 1 + .../security/sqla/apis/role/api.py | 154 +++ .../security/sqla/apis/role/schema.py | 13 + .../security/sqla/apis/user/__init__.py | 1 + .../security/sqla/apis/user/api.py | 209 ++++ .../security/sqla/apis/user/schema.py | 60 + .../security/sqla/apis/user/validator.py | 28 + .../security/sqla/apis/view_menu/__init__.py | 1 + .../security/sqla/apis/view_menu/api.py | 18 + flask_appbuilder/security/sqla/manager.py | 15 + flask_appbuilder/tests/test_security_api.py | 1082 +++++++++++++++++ 16 files changed, 1627 insertions(+) create mode 100644 flask_appbuilder/security/sqla/apis/__init__.py create mode 100644 flask_appbuilder/security/sqla/apis/permission/__init__.py create mode 100644 flask_appbuilder/security/sqla/apis/permission/api.py create mode 100644 flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py create mode 100644 flask_appbuilder/security/sqla/apis/permission_view_menu/api.py create mode 100644 flask_appbuilder/security/sqla/apis/role/__init__.py create mode 100644 flask_appbuilder/security/sqla/apis/role/api.py create mode 100644 flask_appbuilder/security/sqla/apis/role/schema.py create mode 100644 flask_appbuilder/security/sqla/apis/user/__init__.py create mode 100644 flask_appbuilder/security/sqla/apis/user/api.py create mode 100644 flask_appbuilder/security/sqla/apis/user/schema.py create mode 100644 flask_appbuilder/security/sqla/apis/user/validator.py create mode 100644 flask_appbuilder/security/sqla/apis/view_menu/__init__.py create mode 100644 flask_appbuilder/security/sqla/apis/view_menu/api.py create mode 100644 flask_appbuilder/tests/test_security_api.py diff --git a/flask_appbuilder/security/sqla/apis/__init__.py b/flask_appbuilder/security/sqla/apis/__init__.py new file mode 100644 index 0000000000..220edd07c4 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/__init__.py @@ -0,0 +1,7 @@ +from flask_appbuilder.security.sqla.apis.permission import PermissionApi # noqa: F401 +from flask_appbuilder.security.sqla.apis.permission_view_menu import ( # noqa: F401 + PermissionViewMenuApi, +) +from flask_appbuilder.security.sqla.apis.role import RoleApi # noqa: F401 +from flask_appbuilder.security.sqla.apis.user import UserApi # noqa: F401 +from flask_appbuilder.security.sqla.apis.view_menu import ViewMenuApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/permission/__init__.py b/flask_appbuilder/security/sqla/apis/permission/__init__.py new file mode 100644 index 0000000000..fbefe5f12b --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission/__init__.py @@ -0,0 +1 @@ +from .api import PermissionApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/permission/api.py b/flask_appbuilder/security/sqla/apis/permission/api.py new file mode 100644 index 0000000000..19c522ce6b --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission/api.py @@ -0,0 +1,19 @@ +from flask_appbuilder import ModelRestApi +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import Permission + + +class PermissionApi(ModelRestApi): + resource_name = "permissions" + openapi_spec_tag = "Security Permissions" + + class_permission_name = "Permission" + datamodel = SQLAInterface(Permission) + allow_browser_login = True + include_route_methods = {"info", "get", "get_list"} + + list_columns = ["id", "name"] + show_columns = list_columns + add_columns = ["name"] + edit_columns = add_columns + search_columns = list_columns diff --git a/flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py b/flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py new file mode 100644 index 0000000000..bccf326bd0 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission_view_menu/__init__.py @@ -0,0 +1 @@ +from .api import PermissionViewMenuApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py b/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py new file mode 100644 index 0000000000..61b743881c --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py @@ -0,0 +1,17 @@ +from flask_appbuilder import ModelRestApi +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import PermissionView + + +class PermissionViewMenuApi(ModelRestApi): + resource_name = "permissionsviewmenus" + openapi_spec_tag = "Security Permissions View Menus" + class_permission_name = "PermissionViewMenu" + datamodel = SQLAInterface(PermissionView) + allow_browser_login = True + + list_columns = ["id", "permission.name", "view_menu.name"] + show_columns = list_columns + add_columns = ["permission_id", "view_menu_id"] + edit_columns = add_columns + search_columns = list_columns diff --git a/flask_appbuilder/security/sqla/apis/role/__init__.py b/flask_appbuilder/security/sqla/apis/role/__init__.py new file mode 100644 index 0000000000..640bca7c27 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/role/__init__.py @@ -0,0 +1 @@ +from .api import RoleApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/role/api.py b/flask_appbuilder/security/sqla/apis/role/api.py new file mode 100644 index 0000000000..c5853ddc28 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/role/api.py @@ -0,0 +1,154 @@ +from flask import current_app, request +from flask_appbuilder import ModelRestApi +from flask_appbuilder.api import expose, safe +from flask_appbuilder.const import API_RESULT_RES_KEY +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import permission_name, protect +from flask_appbuilder.security.sqla.apis.role.schema import ( + RolePermissionListSchema, + RolePermissionPostSchema, +) +from flask_appbuilder.security.sqla.models import PermissionView, Role +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError + + +class RoleApi(ModelRestApi): + resource_name = "roles" + openapi_spec_tag = "Security Roles" + class_permission_name = "Role" + datamodel = SQLAInterface(Role) + allow_browser_login = True + + list_columns = ["id", "name"] + show_columns = list_columns + add_columns = ["name"] + edit_columns = ["name"] + search_columns = list_columns + + list_role_permission_schema = RolePermissionListSchema() + add_role_permission_schema = RolePermissionPostSchema() + openapi_spec_component_schemas = ( + RolePermissionListSchema, + RolePermissionPostSchema, + ) + + @expose("//permissions", methods=["GET"]) + @protect() + @safe + @permission_name("list_role_permissions") + def list_role_permissions(self, pk): + """list role permissions + --- + get: + parameters: + - in: path + schema: + type: integer + name: pk + responses: + 200: + description: List of permissions + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/RolePermissionListSchema' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + role = self.datamodel.get(pk, select_columns=["permissions"]) + if not role: + return self.response_404() + + permissions = [ + { + "id": p.id, + "permission_name": p.permission.name, + "view_menu_name": p.view_menu.name, + } + for p in role.permissions + ] + return self.response(200, **{API_RESULT_RES_KEY: permissions}) + + @expose("//permissions", methods=["POST"]) + @protect() + @safe + @permission_name("add_role_permissions") + def add_role_permissions(self, role_id): + """add role permissions + --- + post: + parameters: + - in: path + schema: + type: integer + name: role_id + requestBody: + description: Add role permissions schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RolePermissionPostSchema' + responses: + 200: + description: Permissions added + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/RolePermissionPostSchema' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.add_role_permission_schema.load(request.json) + role = self.datamodel.get(role_id) + if not role: + return self.response_404() + permissions = [] + for id in item["permission_view_menu_ids"]: + permission = ( + current_app.appbuilder.get_session.query(PermissionView) + .filter_by(id=id) + .one_or_none() + ) + if permission: + permissions.append(permission) + + role.permissions = permissions + self.datamodel.edit(role, raise_exception=True) + return self.response( + 200, + **{ + API_RESULT_RES_KEY: self.add_role_permission_schema.dump( + item, many=False + ) + }, + ) + + except ValidationError as error: + return self.response_400(message=error.messages) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) diff --git a/flask_appbuilder/security/sqla/apis/role/schema.py b/flask_appbuilder/security/sqla/apis/role/schema.py new file mode 100644 index 0000000000..8dbd59218b --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/role/schema.py @@ -0,0 +1,13 @@ +from marshmallow import fields, Schema + + +class RolePermissionPostSchema(Schema): + permission_view_menu_ids = fields.List( + fields.Integer, required=True, description="List of permission view menu id" + ) + + +class RolePermissionListSchema(Schema): + id = fields.Integer() + permission_name = fields.String() + view_menu_name = fields.String() diff --git a/flask_appbuilder/security/sqla/apis/user/__init__.py b/flask_appbuilder/security/sqla/apis/user/__init__.py new file mode 100644 index 0000000000..44378357a6 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/__init__.py @@ -0,0 +1 @@ +from .api import UserApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/user/api.py b/flask_appbuilder/security/sqla/apis/user/api.py new file mode 100644 index 0000000000..6fd3582bac --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/api.py @@ -0,0 +1,209 @@ +from datetime import datetime + +from flask import current_app +from flask import g, request +from flask_appbuilder import ModelRestApi +from flask_appbuilder.api import expose, safe +from flask_appbuilder.const import API_RESULT_RES_KEY +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import permission_name, protect +from flask_appbuilder.security.sqla.apis.user.schema import ( + UserPostSchema, + UserPutSchema, +) +from flask_appbuilder.security.sqla.models import Role, User +from marshmallow import ValidationError +from sqlalchemy.exc import IntegrityError +from werkzeug.security import generate_password_hash + + +class UserApi(ModelRestApi): + resource_name = "users" + openapi_spec_tag = "Security Users" + class_permission_name = "User" + datamodel = SQLAInterface(User) + allow_browser_login = True + + list_columns = [ + "id", + "roles.id", + "roles.name", + "first_name", + "last_name", + "username", + "active", + "email", + "last_login", + "login_count", + "fail_login_count", + "created_on", + "changed_on", + "created_by.id", + "changed_by.id", + ] + show_columns = list_columns + add_columns = [ + "roles", + "first_name", + "last_name", + "username", + "active", + "email", + "password", + ] + edit_columns = add_columns + search_columns = list_columns + + add_model_schema = UserPostSchema() + edit_model_schema = UserPutSchema() + + def pre_update(self, item): + item.changed_on = datetime.now() + item.changed_by_fk = g.user.id + if item.password: + item.password = generate_password_hash(item.password) + + def pre_add(self, item): + item.password = generate_password_hash(item.password) + + @expose("/", methods=["POST"]) + @protect() + @safe + @permission_name("post") + def post(self): + """Create new user + --- + post: + requestBody: + description: Model schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + responses: + 201: + description: Item changed + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.post' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.add_model_schema.load(request.json) + model = User() + roles = [] + for key, value in item.items(): + if key != "roles": + setattr(model, key, value) + else: + for role_id in item[key]: + role = ( + current_app.appbuilder.get_session.query(Role) + .filter(Role.id == role_id) + .one_or_none() + ) + if role: + role.user_id = model.id + role.role_id = role_id + roles.append(role) + + if "roles" in item.keys(): + model.roles = roles + + self.pre_add(model) + self.datamodel.add(model, raise_exception=True) + return self.response(201, id=model.id) + except ValidationError as error: + return self.response_400(message=error.messages) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) + + @expose("/", methods=["PUT"]) + @protect() + @safe + @permission_name("put") + def put(self, pk): + """Edit user + --- + put: + parameters: + - in: path + schema: + type: integer + name: pk + requestBody: + description: Model schema + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + responses: + 200: + description: Item changed + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/{{self.__class__.__name__}}.put' + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + item = self.edit_model_schema.load(request.json) + model = self.datamodel.get(pk, self._base_filters) + roles = [] + + for key, value in item.items(): + if key != "roles": + setattr(model, key, value) + else: + for role_id in item[key]: + role = ( + current_app.appbuilder.session.query(Role) + .filter(Role.id == role_id) + .one_or_none() + ) + if role: + role.user_id = model.id + role.role_id = role_id + roles.append(role) + + if "roles" in item.keys(): + model.roles = roles + + self.pre_update(model) + self.datamodel.edit(model, raise_exception=True) + return self.response( + 200, + **{API_RESULT_RES_KEY: self.edit_model_schema.dump(item, many=False)}, + ) + + except ValidationError as e: + return self.response_400(message=e.messages) + except IntegrityError as e: + return self.response_422(message=str(e.orig)) diff --git a/flask_appbuilder/security/sqla/apis/user/schema.py b/flask_appbuilder/security/sqla/apis/user/schema.py new file mode 100644 index 0000000000..df0b7f4c15 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/schema.py @@ -0,0 +1,60 @@ +from flask_appbuilder.security.sqla.models import User +from marshmallow import fields, Schema +from marshmallow.validate import Length + +from .validator import PasswordComplexityValidator + +active_description = ( + "Is user active?" "It's not a good policy to remove a user, just make it inactive" +) +email_description = "The user's email" +first_name_description = "The user's first name" +last_name_description = "The user's last name" +password_description = "The user's password for authentication" +roles_description = "The user's roles" +username_description = "The user's username" + + +class UserPostSchema(Schema): + model_cls = User + active = fields.Boolean( + required=False, default=True, description=active_description + ) + email = fields.String(required=True, description=email_description) + first_name = fields.String(required=True, description=first_name_description) + last_name = fields.String(required=True, description=last_name_description) + password = fields.String( + required=True, + validate=[PasswordComplexityValidator()], + description=password_description, + ) + roles = fields.List( + fields.Integer, + required=True, + validate=[Length(1)], + description=roles_description, + ) + username = fields.String( + required=True, validate=[Length(1, 250)], description=username_description + ) + + +class UserPutSchema(Schema): + active = fields.Boolean(required=False, description=active_description) + email = fields.String(required=False, description=email_description) + first_name = fields.String(required=False, description=first_name_description) + last_name = fields.String(required=False, description=last_name_description) + password = fields.String( + required=False, + validate=[PasswordComplexityValidator()], + description=password_description, + ) + roles = fields.List( + fields.Integer, + required=False, + validate=[Length(1)], + description=roles_description, + ) + username = fields.String( + required=False, validate=[Length(1, 250)], description=username_description + ) diff --git a/flask_appbuilder/security/sqla/apis/user/validator.py b/flask_appbuilder/security/sqla/apis/user/validator.py new file mode 100644 index 0000000000..e7dd62ccb3 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/user/validator.py @@ -0,0 +1,28 @@ +from flask import current_app +from flask_appbuilder.exceptions import PasswordComplexityValidationError +from flask_appbuilder.validators import default_password_complexity +from marshmallow.exceptions import ValidationError +from marshmallow.validate import Validator + + +class PasswordComplexityValidator(Validator): + """Validator for password. + """ + + def __call__(self, value: str) -> str: + if current_app.config.get("FAB_PASSWORD_COMPLEXITY_ENABLED", False): + password_complexity_validator = current_app.config.get( + "FAB_PASSWORD_COMPLEXITY_VALIDATOR", None + ) + if password_complexity_validator is not None: + try: + password_complexity_validator(value) + except PasswordComplexityValidationError as exc: + raise ValidationError(str(exc)) + else: + try: + default_password_complexity(value) + except PasswordComplexityValidationError as exc: + raise ValidationError(str(exc)) + + return value diff --git a/flask_appbuilder/security/sqla/apis/view_menu/__init__.py b/flask_appbuilder/security/sqla/apis/view_menu/__init__.py new file mode 100644 index 0000000000..6652d77a00 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/view_menu/__init__.py @@ -0,0 +1 @@ +from .api import ViewMenuApi # noqa: F401 diff --git a/flask_appbuilder/security/sqla/apis/view_menu/api.py b/flask_appbuilder/security/sqla/apis/view_menu/api.py new file mode 100644 index 0000000000..3177d645e7 --- /dev/null +++ b/flask_appbuilder/security/sqla/apis/view_menu/api.py @@ -0,0 +1,18 @@ +from flask_appbuilder import ModelRestApi +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import ViewMenu + + +class ViewMenuApi(ModelRestApi): + resource_name = "viewmenus" + openapi_spec_tag = "Security View Menus" + + class_permission_name = "ViewMenu" + datamodel = SQLAInterface(ViewMenu) + allow_browser_login = True + + list_columns = ["id", "name"] + show_columns = list_columns + add_columns = ["name"] + edit_columns = add_columns + search_columns = list_columns diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index 0db0b2941b..aef28d1650 100755 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import MultipleResultsFound from werkzeug.security import generate_password_hash +from .apis import PermissionApi, PermissionViewMenuApi, RoleApi, UserApi, ViewMenuApi from .models import ( assoc_permissionview_role, Permission, @@ -45,6 +46,13 @@ class SecurityManager(BaseSecurityManager): permissionview_model = PermissionView registeruser_model = RegisterUser + # APIs + permission_api = PermissionApi + role_api = RoleApi + user_api = UserApi + view_menu_api = ViewMenuApi + permission_view_menu_api = PermissionViewMenuApi + def __init__(self, appbuilder): """ SecurityManager contructor @@ -84,6 +92,13 @@ def get_session(self): return self.appbuilder.get_session def register_views(self): + if self.appbuilder.app.config.get("FAB_ADD_SECURITY_API", False): + self.appbuilder.add_api(self.permission_api) + self.appbuilder.add_api(self.role_api) + self.appbuilder.add_api(self.user_api) + self.appbuilder.add_api(self.view_menu_api) + self.appbuilder.add_api(self.permission_view_menu_api) + super(SecurityManager, self).register_views() def create_db(self): diff --git a/flask_appbuilder/tests/test_security_api.py b/flask_appbuilder/tests/test_security_api.py new file mode 100644 index 0000000000..baf0e98a07 --- /dev/null +++ b/flask_appbuilder/tests/test_security_api.py @@ -0,0 +1,1082 @@ +import json +import logging +import os + +from flask_appbuilder import SQLA +from flask_appbuilder.security.sqla.models import Permission, Role, ViewMenu + +from .base import FABTestCase +from .const import PASSWORD_ADMIN, USERNAME_ADMIN + +log = logging.getLogger(__name__) + + +class UserAPITestCase(FABTestCase): + def setUp(self): + from flask import Flask + from flask_appbuilder import AppBuilder + from flask_appbuilder.security.sqla.models import User, Role + + self.app = Flask(__name__) + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config.from_object("flask_appbuilder.tests.config_api") + self.app.config["FAB_ADD_SECURITY_API"] = True + self.db = SQLA(self.app) + self.session = self.db.session + self.appbuilder = AppBuilder(self.app, self.session) + self.user_model = User + self.role_model = Role + + # TODO: this heinous hack is to avoid using stale db session leaking from + # RolePermissionAPITestCase + # don't know why all baseviews in Appbuilder are attached to stale session, + # causing error when adding a new user which reads roles from this session and + # datamodel uses stale session to add it. + for b in self.appbuilder.baseviews: + if hasattr(b, "datamodel") and b.datamodel.session is not None: + b.datamodel.session = self.db.session + + def tearDown(self): + self.appbuilder.get_session.close() + engine = self.db.session.get_bind(mapper=None, clause=None) + engine.dispose() + + def test_user_list(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + total_users = self.appbuilder.sm.count_users() + uri = "api/v1/users/" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 200) + assert "count" in response + self.assertEqual(response["count"], total_users) + self.assertEqual(len(response["result"]), total_users) + + def test_get_single_user(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + username = "test_get_single_user_1" + first_name = "first" + last_name = "last" + email = "test_get_single_user@fab.com" + password = "a" + role_name = "get_single_user_role" + + role = self.appbuilder.sm.add_role(role_name) + user = self.appbuilder.sm.add_user( + username, first_name, last_name, email, role, password + ) + + uri = f"api/v1/users/{user.id}" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 200) + response = json.loads(rv.data) + + assert "result" in response + result = response["result"] + self.assertEqual(result["username"], username) + self.assertEqual(result["first_name"], first_name) + self.assertEqual(result["last_name"], last_name) + self.assertEqual(result["email"], email) + self.assertEqual(result["roles"], [{"id": role.id, "name": role_name}]) + + user = ( + self.session.query(self.user_model) + .filter(self.user_model.id == user.id) + .first() + ) + self.session.delete(user) + role = ( + self.session.query(self.role_model) + .filter(self.role_model.id == role.id) + .first() + ) + self.session.delete(role) + + self.session.commit() + + def test_get_single_invalid_user(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/users/99999999" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 404) + response = json.loads(rv.data) + + assert "message" in response + self.assertEqual(response["message"], "Not found") + + def test_create_user(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + role_name = "test_create_user_api" + role = self.appbuilder.sm.add_role(role_name) + + uri = "api/v1/users/" + create_user_payload = { + "active": True, + "email": "fab@test_create_user_3.com", + "first_name": "fab", + "last_name": "admin", + "password": "password", + "roles": [role.id], + "username": "fab_usear_api_test_4", + } + rv = self.auth_client_post(client, token, uri, create_user_payload) + add_user_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 201) + + assert "id" in add_user_response + + user = self.appbuilder.sm.get_user_by_id(add_user_response["id"]) + + self.assertEqual(user.active, create_user_payload["active"]) + self.assertEqual(user.email, create_user_payload["email"]) + self.assertEqual(user.first_name, create_user_payload["first_name"]) + self.assertEqual(user.last_name, create_user_payload["last_name"]) + self.assertEqual(user.username, create_user_payload["username"]) + self.assertEqual(len(user.roles), 1) + self.assertEqual(user.roles[0].name, role_name) + + user = ( + self.session.query(self.user_model) + .filter(self.user_model.id == user.id) + .first() + ) + self.session.delete(user) + self.session.commit() + + def test_create_user_without_role(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/users/" + create_user_payload = { + "active": True, + "email": "fab@test_create_user_1.com", + "first_name": "fab", + "last_name": "admin", + "password": "password", + "roles": [], + "username": "fab_usear_api_test_2", + } + rv = self.auth_client_post(client, token, uri, create_user_payload) + add_user_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 400) + + assert "message" in add_user_response + self.assertEqual( + add_user_response["message"], {"roles": ["Shorter than minimum length 1."]} + ) + + def test_create_user_with_invalid_role(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/users/" + create_user_payload = { + "active": True, + "email": "fab@test_create_user_1.com", + "first_name": "fab", + "last_name": "admin", + "password": "password", + "roles": [999999], + "username": "fab_usear_api_test_2", + } + rv = self.auth_client_post(client, token, uri, create_user_payload) + add_user_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 201) + + user = self.appbuilder.sm.get_user_by_id(add_user_response["id"]) + + self.assertEqual(user.active, create_user_payload["active"]) + self.assertEqual(user.email, create_user_payload["email"]) + self.assertEqual(user.first_name, create_user_payload["first_name"]) + self.assertEqual(user.last_name, create_user_payload["last_name"]) + self.assertEqual(user.username, create_user_payload["username"]) + self.assertEqual(len(user.roles), 0) + + user = ( + self.session.query(self.user_model) + .filter(self.user_model.id == user.id) + .first() + ) + self.session.delete(user) + self.session.commit() + + def test_edit_user(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + username = "edit_user_13" + first_name = "first" + last_name = "last" + email = "test_edit_user13@fab.com" + password = "a" + role_name_1 = "edit_user_role_1" + role_name_2 = "edit_user_role_2" + role_name_3 = "edit_user_role_3" + updated_email = "test_edit_user_new7@fab.com" + + role_1 = self.appbuilder.sm.add_role(role_name_1) + role_2 = self.appbuilder.sm.add_role(role_name_2) + role_3 = self.appbuilder.sm.add_role(role_name_3) + user = self.appbuilder.sm.add_user( + username, first_name, last_name, email, [role_1], password + ) + + user_id = user.id + role_1_id = role_1.id + role_2_id = role_2.id + role_3_id = role_3.id + + uri = f"api/v1/users/{user_id}" + rv = self.auth_client_put( + client, + token, + uri, + {"email": updated_email, "roles": [role_2_id, role_3_id]}, + ) + self.assertEqual(rv.status_code, 200) + updated_user = self.appbuilder.sm.get_user_by_id(user_id) + self.assertEqual(len(updated_user.roles), 2) + self.assertEqual(updated_user.roles[0].name, role_name_2) + self.assertEqual(updated_user.roles[1].name, role_name_3) + self.assertEqual(updated_user.email, updated_email) + + roles = ( + self.session.query(self.role_model) + .filter(self.role_model.id.in_([role_1_id, role_2_id, role_3_id])) + .all() + ) + user = ( + self.session.query(self.user_model) + .filter(self.user_model.id == user_id) + .first() + ) + self.session.delete(user) + for r in roles: + self.session.delete(r) + self.session.commit() + + def test_delete_user(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + username = "delete_user_2" + first_name = "first" + last_name = "last" + email = "test_delete_user_2@fab.com" + password = "a" + role_name_1 = "delete_user_role_2" + + role = self.appbuilder.sm.add_role(role_name_1) + user = self.appbuilder.sm.add_user( + username, first_name, last_name, email, [role], password + ) + role_id = role.id + user_id = user.id + + uri = f"api/v1/users/{user_id}" + rv = self.auth_client_delete(client, token, uri) + self.assertEqual(rv.status_code, 200) + + updated_user = self.appbuilder.sm.get_user_by_id(user_id) + assert not updated_user + + role = ( + self.session.query(self.role_model) + .filter(self.role_model.id == role_id) + .first() + ) + self.session.delete(role) + self.session.commit() + + +class RolePermissionAPITestCase(FABTestCase): + def setUp(self): + from flask import Flask + from flask_appbuilder import AppBuilder + + self.app = Flask(__name__) + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config.from_object("flask_appbuilder.tests.config_api") + self.app.config["FAB_ADD_SECURITY_API"] = True + self.db = SQLA(self.app) + self.session = self.db.session + self.appbuilder = AppBuilder(self.app, self.db.session) + self.permission_model = Permission + self.viewmenu_model = ViewMenu + self.role_model = Role + + for b in self.appbuilder.baseviews: + if hasattr(b, "datamodel") and b.datamodel.session is not None: + b.datamodel.session = self.db.session + + def tearDown(self): + self.appbuilder.get_session.close() + engine = self.db.session.get_bind(mapper=None, clause=None) + engine.dispose() + + def test_list_permission_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + count = self.session.query(self.permission_model).count() + + uri = "api/v1/permissions/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 200) + + response = json.loads(rv.data) + + assert "count" and "result" in response + self.assertEqual(response["count"], count) + + def test_get_permission_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + permission_name = "test_get_permission_api_1" + permission = self.appbuilder.sm.add_permission(permission_name) + permission_id = permission.id + + uri = f"api/v1/permissions/{permission_id}" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 200) + + response = json.loads(rv.data) + + assert "id" and "result" in response + self.assertEqual(response["id"], permission_id) + self.assertEqual(response["result"]["name"], permission_name) + + self.session.delete(permission) + + def test_get_invalid_permission_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/permissions/9999999" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 404) + self.assertEqual(response, {"message": "Not found"}) + + def test_add_permission_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/permissions/" + permission_name = "super duper fab permission" + + create_permission_payload = {"name": permission_name} + rv = self.auth_client_post(client, token, uri, create_permission_payload) + self.assertEqual(rv.status_code, 405) + permission = self.appbuilder.sm.find_permission(permission_name) + assert permission is None + + def test_edit_permission_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + permission_name = "test_edit_permission_api_2" + new_permission_name = "different_test_edit_permission_api_2" + permission = self.appbuilder.sm.add_permission(permission_name) + permission_id = permission.id + + uri = f"api/v1/permissions/{permission_id}" + rv = self.auth_client_put(client, token, uri, {"name": new_permission_name}) + + self.assertEqual(rv.status_code, 405) + + new_permission = self.appbuilder.sm.find_permission(new_permission_name) + assert new_permission is None + + self.appbuilder.sm.del_permission(permission_name) + + def test_delete_permission_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + permission_name = "test_delete_permission_api" + permission = self.appbuilder.sm.add_permission(permission_name) + + uri = f"api/v1/permissions/{permission.id}" + rv = self.auth_client_delete(client, token, uri) + self.assertEqual(rv.status_code, 405) + + new_permission = self.appbuilder.sm.find_permission(permission_name) + assert new_permission is not None + self.appbuilder.sm.del_permission(permission_name) + + def test_list_view_api(self): + """REST Api: Test view apis + """ + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + count = self.session.query(self.viewmenu_model).count() + + uri = "api/v1/viewmenus/" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 200) + assert "count" and "result" in response + self.assertEqual(response["count"], count) + + def test_get_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + view_name = "test_get_view_api" + view = self.appbuilder.sm.add_view_menu(view_name) + view_id = view.id + + uri = f"api/v1/viewmenus/{view_id}" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 200) + assert "id" and "result" in response + self.assertEqual(response["id"], view_id) + self.assertEqual(response["result"]["name"], view_name) + + self.session.delete(view) + + def test_get_invalid_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/viewmenus/99999999" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 404) + self.assertEqual(response, {"message": "Not found"}) + + def test_add_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + view_name = "super duper fab view" + uri = "api/v1/viewmenus/" + create_permission_payload = {"name": view_name} + rv = self.auth_client_post(client, token, uri, create_permission_payload) + add_permission_response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 201) + assert "id" and "result" in add_permission_response + self.assertEqual(create_permission_payload, add_permission_response["result"]) + + self.appbuilder.sm.del_view_menu(view_name) + + def test_add_view_without_name_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/viewmenus/" + create_view_payload = {} + rv = self.auth_client_post(client, token, uri, create_view_payload) + add_permission_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 422) + assert "message" in add_permission_response + self.assertEqual( + {"message": {"name": ["Missing data for required field."]}}, + add_permission_response, + ) + + def test_edit_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + view_name = "test_edit_view_api" + new_view_name = "different_test_edit_view_api" + view_menu = self.appbuilder.sm.add_view_menu(view_name) + view_menu_id = view_menu.id + + uri = f"api/v1/viewmenus/{view_menu_id}" + rv = self.auth_client_put(client, token, uri, {"name": new_view_name}) + put_permission_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 200) + self.assertEqual( + put_permission_response["result"].get("name", ""), new_view_name + ) + + new_view = self.appbuilder.sm.find_view_menu(new_view_name) + assert new_view + self.assertEqual(new_view.name, new_view_name) + + self.appbuilder.sm.del_view_menu(new_view_name) + + def test_delete_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + view_menu_name = "test_delete_view_api" + view_menu = self.appbuilder.sm.add_view_menu(view_menu_name) + + uri = f"api/v1/viewmenus/{view_menu.id}" + rv = self.auth_client_delete(client, token, uri) + self.assertEqual(rv.status_code, 200) + + new_view_menu = self.appbuilder.sm.find_view_menu(view_menu_name) + assert new_view_menu is None + + def test_list_permission_view_api(self): + """REST Api: Test permission view apis + """ + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/permissionsviewmenus/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 200) + + def test_get_permission_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + permission_name = "test_get_permission_view_permission" + view_name = "test_get_permission_view_view" + permission_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_name, view_name + ) + + uri = f"api/v1/permissionsviewmenus/{permission_view_menu.id}" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 200) + + self.appbuilder.sm.del_permission_view_menu(permission_name, view_name, True) + + def test_get_invalid_permission_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/permissionsviewmenus/9999999" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 404) + + def test_add_permission_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + permission_name = "test_add_permission_3" + view_menu_name = "test_add_view_3" + + permission = self.appbuilder.sm.add_permission(permission_name) + view_menu = self.appbuilder.sm.add_view_menu(view_menu_name) + + uri = "api/v1/permissionsviewmenus/" + create_permission_payload = { + "permission_id": permission.id, + "view_menu_id": view_menu.id, + } + rv = self.auth_client_post(client, token, uri, create_permission_payload) + add_permission_response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 201) + assert "id" and "result" in add_permission_response + self.assertEqual(create_permission_payload, add_permission_response["result"]) + + self.appbuilder.sm.del_permission_view_menu( + permission_name, view_menu_name, True + ) + + def test_edit_permission_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + permission_name = "test_edit_permission_view_permission" + view_name = "test_edit_permission_view" + new_view_name = "test_edit_permission_view_new" + permission_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_name, view_name + ) + new_view_menu = self.appbuilder.sm.add_view_menu(new_view_name) + + new_view_menu_id = new_view_menu.id + + uri = f"api/v1/permissionsviewmenus/{permission_view_menu.id}" + rv = self.auth_client_put( + client, token, uri, {"view_menu_id": new_view_menu.id} + ) + put_permission_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 200) + self.assertEqual( + put_permission_response["result"].get("view_menu_id", None), + new_view_menu_id, + ) + + self.appbuilder.sm.del_view_menu(view_name) + self.appbuilder.sm.del_permission_view_menu( + permission_name, new_view_name, True + ) + + def test_delete_permission_view_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + permission_name = "test_delete_permission_view_permission_3" + view_name = "test_get_permission_view_3" + permission_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_name, view_name + ) + + uri = f"api/v1/permissionsviewmenus/{permission_view_menu.id}" + rv = self.auth_client_delete(client, token, uri) + self.assertEqual(rv.status_code, 200) + + pvm = self.appbuilder.sm.find_permission_view_menu(permission_name, view_name) + assert pvm is None + + def test_list_role_api(self): + """REST Api: Test role apis + """ + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/roles/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 200) + + def test_get_role_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + role_name = "test_get_role_api_3" + role = self.appbuilder.sm.add_role(role_name) + role_id = role.id + + uri = f"api/v1/roles/{role_id}" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 200) + assert "id" and "result" in response + self.assertEqual(response["result"].get("name", ""), role_name) + + self.session.delete(role) + self.session.commit() + + def test_create_role_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/roles/" + role_name = "test_create_role_api" + create_user_payload = {"name": role_name} + rv = self.auth_client_post(client, token, uri, create_user_payload) + add_role_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 201) + assert "id" and "result" in add_role_response + self.assertEqual(create_user_payload, add_role_response["result"]) + + role = self.session.query(self.role_model).filter_by(name=role_name).first() + self.session.delete(role) + self.session.commit() + + def test_edit_role_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + num = 3 + role_name = f"test_edit_role_api_{num}" + role_2_name = f"test_edit_role_api_{num+1}" + permission_1_name = f"test_edit_role_permission_{num}" + permission_2_name = f"test_edit_role_permission_{num+1}" + view_menu_name = f"test_edit_role_view_menu_{num}" + + role = self.appbuilder.sm.add_role(role_name) + + role_id = role.id + + uri = f"api/v1/roles/{role_id}" + rv = self.auth_client_put(client, token, uri, {"name": role_2_name}) + + put_role_response = json.loads(rv.data) + self.assertEqual(rv.status_code, 200) + + self.assertEqual(put_role_response["result"].get("name", ""), role_2_name) + + self.appbuilder.sm.del_permission_view_menu( + permission_1_name, view_menu_name, False + ) + self.appbuilder.sm.del_permission_view_menu( + permission_2_name, view_menu_name, False + ) + self.appbuilder.sm.del_permission(permission_1_name) + self.appbuilder.sm.del_permission(permission_2_name) + self.appbuilder.sm.del_view_menu(view_menu_name) + + role = self.appbuilder.sm.find_role(role_2_name) + + self.session.delete(role) + self.session.commit() + + def test_add_view_menu_permissions_to_role(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + num = 1 + role_name = f"test_edit_role_api_{num}" + permission_1_name = f"test_edit_role_permission_{num}" + permission_2_name = f"test_edit_role_permission_{num+1}" + view_menu_name = f"test_edit_role_view_menu_{num}" + + permission_1_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_1_name, view_menu_name + ) + permission_2_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_2_name, view_menu_name + ) + role = self.appbuilder.sm.add_role(role_name) + role_id = role.id + permission_1_view_menu_id = permission_1_view_menu.id + permission_2_view_menu_id = permission_2_view_menu.id + + uri = f"api/v1/roles/{role_id}/permissions" + rv = self.auth_client_post( + client, + token, + uri, + { + "permission_view_menu_ids": [ + permission_1_view_menu.id, + permission_2_view_menu.id, + ] + }, + ) + + post_permissions_response = json.loads(rv.data) + + self.assertEqual(rv.status_code, 200) + assert "result" in post_permissions_response + self.assertEqual( + post_permissions_response["result"]["permission_view_menu_ids"], + [permission_1_view_menu_id, permission_2_view_menu_id], + ) + + role = self.appbuilder.sm.find_role(role_name) + + self.assertEqual(len(role.permissions), 2) + self.assertEqual( + [p.id for p in role.permissions], + [permission_1_view_menu_id, permission_2_view_menu_id], + ) + + role = self.appbuilder.sm.find_role(role_name) + self.session.delete(role) + + self.appbuilder.sm.del_permission_view_menu( + permission_1_name, view_menu_name, cascade=True + ) + self.appbuilder.sm.del_permission_view_menu( + permission_2_name, view_menu_name, cascade=True + ) + + def test_add_invalid_view_menu_permissions_to_role(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + num = 1 + role_name = f"test_add_permissions_to_role_api_{num}" + + role = self.appbuilder.sm.add_role(role_name) + role_id = role.id + + uri = f"api/v1/roles/{role_id}/permissions" + rv = self.auth_client_post(client, token, uri, {}) + + self.assertEqual(rv.status_code, 400) + role = self.appbuilder.sm.find_role(role_name) + self.session.delete(role) + + def test_add_view_menu_permissions_to_invalid_role(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + num = 1 + permission_1_name = f"test_edit_role_permission_{num}" + permission_2_name = f"test_edit_role_permission_{num+1}" + view_menu_name = f"test_edit_role_view_menu_{num}" + + permission_1_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_1_name, view_menu_name + ) + permission_2_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_2_name, view_menu_name + ) + + uri = f"api/v1/roles/{9999999}/permissions" + rv = self.auth_client_post( + client, + token, + uri, + { + "permission_view_menu_ids": [ + permission_1_view_menu.id, + permission_2_view_menu.id, + ] + }, + ) + self.assertEqual(rv.status_code, 404) + self.appbuilder.sm.del_permission_view_menu( + permission_1_name, view_menu_name, cascade=True + ) + self.appbuilder.sm.del_permission_view_menu( + permission_2_name, view_menu_name, cascade=True + ) + + def test_list_view_menu_permissions_of_role(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + num = 1 + role_name = f"test_list_role_api_{num}" + permission_1_name = f"test_list_role_permission_{num}" + permission_2_name = f"test_list_role_permission_{num+1}" + view_menu_name = f"test_list_role_view_menu_{num}" + + permission_1_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_1_name, view_menu_name + ) + permission_2_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_2_name, view_menu_name + ) + role = self.appbuilder.sm.add_role(role_name) + self.appbuilder.sm.add_permission_role(role, permission_1_view_menu) + self.appbuilder.sm.add_permission_role(role, permission_2_view_menu) + + role_id = role.id + permission_1_view_menu_id = permission_1_view_menu.id + permission_2_view_menu_id = permission_2_view_menu.id + + uri = f"api/v1/roles/{role_id}/permissions" + rv = self.auth_client_get(client, token, uri) + + self.assertEqual(rv.status_code, 200) + + list_permissions_response = json.loads(rv.data) + + assert "result" in list_permissions_response + self.assertEqual(len(list_permissions_response["result"]), 2) + self.assertEqual( + list_permissions_response["result"], + [ + { + "id": permission_1_view_menu_id, + "permission_name": permission_1_name, + "view_menu_name": view_menu_name, + }, + { + "id": permission_2_view_menu_id, + "permission_name": permission_2_name, + "view_menu_name": view_menu_name, + }, + ], + ) + + role = self.appbuilder.sm.find_role(role_name) + self.session.delete(role) + + def test_list_view_menu_permissions_of_invalid_role(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = f"api/v1/roles/{999999}/permissions" + rv = self.auth_client_get(client, token, uri) + + self.assertEqual(rv.status_code, 404) + + def test_delete_role_api(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + role_name = "test_delete_role_api" + permission_1_name = "test_delete_role_permission" + view_menu_name = "test_delete_role_view_menu" + + permission_1_view_menu = self.appbuilder.sm.add_permission_view_menu( + permission_1_name, view_menu_name + ) + role = self.appbuilder.sm.add_role(role_name, [permission_1_view_menu]) + + uri = f"api/v1/roles/{role.id}" + rv = self.auth_client_delete(client, token, uri) + self.assertEqual(rv.status_code, 200) + + get_role = self.appbuilder.sm.find_role(role_name) + assert get_role is None + + +class UserRolePermissionDisabledTestCase(FABTestCase): + def setUp(self): + from flask import Flask + from flask_appbuilder import AppBuilder + + self.app = Flask(__name__) + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config.from_object("flask_appbuilder.tests.config_api") + self.db = SQLA(self.app) + self.appbuilder = AppBuilder(self.app, self.db.session) + + def tearDown(self): + self.appbuilder.get_session.close() + engine = self.db.session.get_bind(mapper=None, clause=None) + engine.dispose() + + def test_user_role_permission(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/users/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 404) + + uri = "api/v1/roles/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 404) + + uri = "api/v1/permissions/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 404) + + uri = "api/v1/viewmenus/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 404) + + uri = "api/v1/permissionsviewmenus/" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 404) + + +class UserCustomPasswordComplexityValidatorTestCase(FABTestCase): + def setUp(self): + from flask import Flask + from flask_appbuilder import AppBuilder + from flask_appbuilder.exceptions import PasswordComplexityValidationError + from flask_appbuilder.security.sqla.models import User + + def passwordValidator(password): + if len(password) < 5: + raise PasswordComplexityValidationError + + self.app = Flask(__name__) + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config.from_object("flask_appbuilder.tests.config_api") + self.app.config["FAB_ADD_SECURITY_API"] = True + self.app.config["FAB_PASSWORD_COMPLEXITY_ENABLED"] = True + self.app.config["FAB_PASSWORD_COMPLEXITY_VALIDATOR"] = passwordValidator + self.db = SQLA(self.app) + self.appbuilder = AppBuilder(self.app, self.db.session) + self.user_model = User + + # TODO:remove this hack + for b in self.appbuilder.baseviews: + if hasattr(b, "datamodel") and b.datamodel.session is not None: + b.datamodel.session = self.db.session + + def tearDown(self): + self.appbuilder.get_session.close() + engine = self.db.session.get_bind(mapper=None, clause=None) + engine.dispose() + + def test_password_complexity(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/users/" + create_user_payload = { + "active": True, + "email": "fab@usertest1.com", + "first_name": "fab", + "last_name": "admin", + "password": "a", + "roles": [1], + "username": "password complexity test user 10", + } + rv = self.auth_client_post(client, token, uri, create_user_payload) + self.assertEqual(rv.status_code, 400) + + create_user_payload["password"] = "bigger password" + rv = self.auth_client_post(client, token, uri, create_user_payload) + self.assertEqual(rv.status_code, 201) + + session = self.appbuilder.get_session + user = ( + session.query(self.user_model) + .filter(self.user_model.username == "password complexity test user 10") + .one_or_none() + ) + session.delete(user) + session.commit() + + +class UserDefaultPasswordComplexityValidatorTestCase(FABTestCase): + def setUp(self): + from flask import Flask + from flask_appbuilder import AppBuilder + from flask_appbuilder.exceptions import PasswordComplexityValidationError + from flask_appbuilder.security.sqla.models import User + + def passwordValidator(password): + if len(password) < 5: + raise PasswordComplexityValidationError + + self.app = Flask(__name__) + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config.from_object("flask_appbuilder.tests.config_api") + self.app.config["FAB_ADD_SECURITY_API"] = True + self.app.config["FAB_PASSWORD_COMPLEXITY_ENABLED"] = True + self.db = SQLA(self.app) + self.appbuilder = AppBuilder(self.app, self.db.session) + self.user_model = User + + # TODO:remove this hack + for b in self.appbuilder.baseviews: + if hasattr(b, "datamodel") and b.datamodel.session is not None: + b.datamodel.session = self.db.session + + def tearDown(self): + self.appbuilder.get_session.close() + engine = self.db.session.get_bind(mapper=None, clause=None) + engine.dispose() + + def test_password_complexity(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/users/" + create_user_payload = { + "active": True, + "email": "fab@defalultpasswordtest.com", + "first_name": "fab", + "last_name": "admin", + "password": "this is very big pasword", + "roles": [1], + "username": "password complexity test user", + } + rv = self.auth_client_post(client, token, uri, create_user_payload) + self.assertEqual(rv.status_code, 400) + + create_user_payload["password"] = "AB@12abcef" + rv = self.auth_client_post(client, token, uri, create_user_payload) + self.assertEqual(rv.status_code, 201) + + session = self.appbuilder.get_session + user = ( + session.query(self.user_model) + .filter(self.user_model.username == "password complexity test user") + .one_or_none() + ) + session.delete(user) + session.commit() From dbd060bf8a4e141daed1fcba15447ff6cfd58ff0 Mon Sep 17 00:00:00 2001 From: Geido <60598000+geido@users.noreply.github.com> Date: Tue, 12 Apr 2022 14:52:51 +0300 Subject: [PATCH 008/113] chore: Enhance is_safe_redirect_url (#1826) * Enhance is_safe_redirect_url * Enhance tests * Update test * Update test_db_login_invalid_control_characters_next_url * Update contributing doc * Fix test * Force same netloc Co-authored-by: Daniel Vaz Gaspar --- CONTRIBUTING.rst | 2 +- .../tests/security/test_mvc_security.py | 84 +++++++++++++++++-- flask_appbuilder/tests/test_mvc_oauth.py | 2 +- flask_appbuilder/utils/base.py | 24 ++++-- 4 files changed, 98 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 2fd15600fa..db02629eef 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -69,7 +69,7 @@ Using Postgres .. code-block:: bash - $ nosetests -v flask_appbuilder.tests.test_A_fixture + $ nosetests -v flask_appbuilder.tests.A_fixture 4 - Run a single test diff --git a/flask_appbuilder/tests/security/test_mvc_security.py b/flask_appbuilder/tests/security/test_mvc_security.py index ec18b8015f..9411e88907 100644 --- a/flask_appbuilder/tests/security/test_mvc_security.py +++ b/flask_appbuilder/tests/security/test_mvc_security.py @@ -109,30 +109,100 @@ def test_db_login_valid_next_url(self): ) assert response.location == "http://localhost/users/list/" - def test_db_login_valid_full_next_url(self): + def test_db_login_valid_http_scheme_url(self): """ - Test Security valid full next URL + Test Security valid http scheme next URL """ self.browser_logout(self.client) response = self.browser_login( self.client, USERNAME_ADMIN, PASSWORD_ADMIN, - next_url="http://localhost/users/add", + next_url="http://localhost/path", follow_redirects=False, ) - assert response.location == "http://localhost/users/add" + assert response.location == "http://localhost/path" - def test_db_login_invalid_next_url(self): + def test_db_login_valid_https_scheme_url(self): """ - Test Security invalid next URL + Test Security valid https scheme next URL """ self.browser_logout(self.client) response = self.browser_login( self.client, USERNAME_ADMIN, PASSWORD_ADMIN, - next_url="https://www.google.com", + next_url="https://localhost/path", + follow_redirects=False, + ) + assert response.location == "https://localhost/path" + + def test_db_login_invalid_external_next_url(self): + """ + Test Security invalid external next URL + """ + self.browser_logout(self.client) + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="https://google.com", + follow_redirects=False, + ) + assert response.location == "http://localhost/" + + def test_db_login_invalid_scheme_next_url(self): + """ + Test Security invalid scheme next URL + """ + self.browser_logout(self.client) + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="ftp://sample", + follow_redirects=False, + ) + assert response.location == "http://localhost/" + + def test_db_login_invalid_localhost_file_next_url(self): + """ + Test Security invalid path to localhost file next URL + """ + self.browser_logout(self.client) + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="file:///path", + follow_redirects=False, + ) + assert response.location == "http://localhost/" + + def test_db_login_invalid_no_netloc_with_scheme_next_url(self): + """ + Test Security invalid next URL with no netloc but with scheme + """ + self.browser_logout(self.client) + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url="http:///sample.com ", + follow_redirects=False, + ) + assert response.location == "http://localhost/" + + def test_db_login_invalid_control_characters_next_url(self): + """ + Test Security invalid next URL with control characters + """ + self.browser_logout(self.client) + response = self.browser_login( + self.client, + USERNAME_ADMIN, + PASSWORD_ADMIN, + next_url=u"\u0001" + "sample.com", follow_redirects=False, ) assert response.location == "http://localhost/" diff --git a/flask_appbuilder/tests/test_mvc_oauth.py b/flask_appbuilder/tests/test_mvc_oauth.py index 4620226839..42a7184707 100644 --- a/flask_appbuilder/tests/test_mvc_oauth.py +++ b/flask_appbuilder/tests/test_mvc_oauth.py @@ -86,7 +86,7 @@ def test_oauth_login_next_check(self): self.appbuilder.sm.oauth_remotes = {"google": OAuthRemoteMock()} - raw_state = {"next": ["http://www.google.com"]} + raw_state = {"next": ["ftp://sample"]} state = jwt.encode(raw_state, self.app.config["SECRET_KEY"], algorithm="HS256") response = client.get(f"/oauth-authorized/google?state={state}") diff --git a/flask_appbuilder/utils/base.py b/flask_appbuilder/utils/base.py index 97a7396cd1..cbf284c364 100644 --- a/flask_appbuilder/utils/base.py +++ b/flask_appbuilder/utils/base.py @@ -1,6 +1,7 @@ import logging from typing import Any, Callable -from urllib.parse import urljoin, urlparse +import unicodedata +from urllib.parse import urlparse from flask import current_app, request from flask_babel import gettext @@ -11,11 +12,24 @@ def is_safe_redirect_url(url: str) -> bool: + if url.startswith("///"): + return False + try: + url_info = urlparse(url) + except ValueError: + return False + if not url_info.netloc and url_info.scheme: + return False + if unicodedata.category(url[0])[0] == "C": + return False + scheme = url_info.scheme + # Consider URLs without a scheme (e.g. //example.com/p) to be http. + if not url_info.scheme and url_info.netloc: + scheme = "http" + valid_schemes = ["http", "https"] host_url = urlparse(request.host_url) - redirect_url = urlparse(urljoin(request.host_url, url)) - return ( - redirect_url.scheme in ("http", "https") - and host_url.netloc == redirect_url.netloc + return (not url_info.netloc or url_info.netloc == host_url.netloc) and ( + not scheme or scheme in valid_schemes ) From b21138271f137179bdb691b4abb7bcc027dd7a4e Mon Sep 17 00:00:00 2001 From: Dosenpfand Date: Tue, 12 Apr 2022 14:11:22 +0200 Subject: [PATCH 009/113] chore: Update and fix german translation (#1827) * Fix german translation for "user registrations" * Update, fix, complete german translation Co-authored-by: Daniel Vaz Gaspar --- .../translations/de/LC_MESSAGES/messages.po | 418 ++++++++++++------ 1 file changed, 281 insertions(+), 137 deletions(-) diff --git a/flask_appbuilder/translations/de/LC_MESSAGES/messages.po b/flask_appbuilder/translations/de/LC_MESSAGES/messages.po index dfeb742cc4..09fe15ddac 100644 --- a/flask_appbuilder/translations/de/LC_MESSAGES/messages.po +++ b/flask_appbuilder/translations/de/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2015-09-17 13:23+0100\n" +"POT-Creation-Date: 2018-04-26 22:56+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -16,45 +16,49 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.3.4\n" +"Generated-By: Babel 2.9.1\n" -#: flask_appbuilder/const.py:100 msgid "Access is Denied" msgstr "Zugriff verweigert" -#: flask_appbuilder/fields.py:77 flask_appbuilder/fields.py:79 -#: flask_appbuilder/fields.py:131 flask_appbuilder/fields.py:138 +#: flask_appbuilder/fields.py:133 flask_appbuilder/fields.py:185 +#: flask_appbuilder/fields.py:192 flask_appbuilder/fields.py:242 #, fuzzy msgid "Not a valid choice" msgstr "Kein gültiger Datumswert" +#: flask_appbuilder/fieldwidgets.py:153 flask_appbuilder/fieldwidgets.py:172 +msgid "Select Value" +msgstr "Wert auswählen" + #: flask_appbuilder/messages.py:9 #: flask_appbuilder/templates/appbuilder/general/charts/chart.html:8 #: flask_appbuilder/templates/appbuilder/general/charts/chart_time.html:10 #: flask_appbuilder/templates/appbuilder/general/charts/jsonchart.html:8 -#: flask_appbuilder/templates/appbuilder/general/lib.html:305 +#: flask_appbuilder/templates/appbuilder/general/lib.html:303 #: flask_appbuilder/templates/appbuilder/general/model/list.html:8 msgid "Search" msgstr "Suchen" +#: examples/quickhowto2/app/templates/widgets/list_override.html:9 #: flask_appbuilder/messages.py:10 -#: flask_appbuilder/templates/appbuilder/general/lib.html:312 +#: flask_appbuilder/templates/appbuilder/general/lib.html:310 msgid "Back" msgstr "Zurück" #: flask_appbuilder/messages.py:11 -#: flask_appbuilder/templates/appbuilder/general/lib.html:265 +#: flask_appbuilder/templates/appbuilder/general/lib.html:263 msgid "Save" msgstr "Speichern" -#: flask_appbuilder/messages.py:12 msgid "This field is required." msgstr "Dieses Feld ist erforderlich." -#: flask_appbuilder/messages.py:13 msgid "Not a valid date value" msgstr "Kein gültiger Datumswert" +#: examples/issue_169/app/templates/widgets/list.html:70 +#: examples/quickhowto2/app/templates/widgets/list.html:70 #: flask_appbuilder/messages.py:14 #: flask_appbuilder/templates/appbuilder/general/widgets/base_list.html:36 #: flask_appbuilder/templates/appbuilder/general/widgets/list_carousel.html:50 @@ -62,7 +66,7 @@ msgstr "Kein gültiger Datumswert" msgid "No records found" msgstr "Keine Datensätze gefunden" -#: flask_appbuilder/upload.py:136 flask_appbuilder/upload.py:182 +#: flask_appbuilder/upload.py:211 msgid "Invalid file extension" msgstr "Ungültige Dateierweiterung" @@ -74,76 +78,79 @@ msgstr "Bereits vorhanden." msgid "Group by" msgstr "Gruppieren nach" -#: flask_appbuilder/models/base.py:21 +#: flask_appbuilder/models/base.py:27 msgid "Added Row" msgstr "Zeile hinzugefügt" -#: flask_appbuilder/models/base.py:22 +#: flask_appbuilder/models/base.py:28 msgid "Changed Row" msgstr "Zeile geändert" -#: flask_appbuilder/models/base.py:23 +#: flask_appbuilder/models/base.py:29 msgid "Deleted Row" msgstr "Zeile gelöscht" -#: flask_appbuilder/models/base.py:24 +#: flask_appbuilder/models/base.py:30 msgid "Associated data exists, please delete them first" msgstr "Es existieren assoziierte Daten, bitte löschen Sie diese zuerst" -#: flask_appbuilder/models/base.py:25 flask_appbuilder/models/base.py:26 +#: flask_appbuilder/models/base.py:31 flask_appbuilder/models/base.py:32 msgid "Integrity error, probably unique constraint" msgstr "Integritätsfehler, wahrscheinlich eindeutige Einschränkung" -#: flask_appbuilder/models/base.py:27 +#: flask_appbuilder/models/base.py:33 msgid "General Error" msgstr "Allgemeiner Fehler" -#: flask_appbuilder/models/group.py:25 +#: flask_appbuilder/models/group.py:26 msgid "Count of" msgstr "Anzahl" -#: flask_appbuilder/models/group.py:34 +#: flask_appbuilder/models/group.py:35 msgid "Sum of" msgstr "Summe" -#: flask_appbuilder/models/group.py:42 +#: flask_appbuilder/models/group.py:44 msgid "Avg. of" msgstr "Durchschn." #: flask_appbuilder/models/generic/filters.py:10 -#: flask_appbuilder/models/generic/filters.py:19 #: flask_appbuilder/models/mongoengine/filters.py:66 -#: flask_appbuilder/models/sqla/filters.py:43 +#: flask_appbuilder/models/sqla/filters.py:92 msgid "Contains" msgstr "Enthält" +#: flask_appbuilder/models/generic/filters.py:19 +msgid "Contains (insensitive)" +msgstr "" + #: flask_appbuilder/models/generic/filters.py:25 #: flask_appbuilder/models/mongoengine/filters.py:74 -#: flask_appbuilder/models/sqla/filters.py:50 +#: flask_appbuilder/models/sqla/filters.py:100 msgid "Not Contains" msgstr "Enthält nicht" #: flask_appbuilder/models/generic/filters.py:31 #: flask_appbuilder/models/mongoengine/filters.py:12 -#: flask_appbuilder/models/sqla/filters.py:57 +#: flask_appbuilder/models/sqla/filters.py:108 msgid "Equal to" msgstr "Ist gleich" #: flask_appbuilder/models/generic/filters.py:37 #: flask_appbuilder/models/mongoengine/filters.py:23 -#: flask_appbuilder/models/sqla/filters.py:67 +#: flask_appbuilder/models/sqla/filters.py:117 msgid "Not Equal to" msgstr "Ist nicht gleich" #: flask_appbuilder/models/generic/filters.py:43 #: flask_appbuilder/models/mongoengine/filters.py:34 -#: flask_appbuilder/models/sqla/filters.py:77 +#: flask_appbuilder/models/sqla/filters.py:126 msgid "Greater than" -msgstr "Grösser als" +msgstr "Größer als" #: flask_appbuilder/models/generic/filters.py:49 #: flask_appbuilder/models/mongoengine/filters.py:42 -#: flask_appbuilder/models/sqla/filters.py:84 +#: flask_appbuilder/models/sqla/filters.py:135 msgid "Smaller than" msgstr "Kleiner als" @@ -153,34 +160,34 @@ msgid "Start with" msgstr "Beginnt mit" #: flask_appbuilder/models/mongoengine/filters.py:50 -#: flask_appbuilder/models/sqla/filters.py:15 +#: flask_appbuilder/models/sqla/filters.py:60 msgid "Starts with" msgstr "Beginnt mit" #: flask_appbuilder/models/mongoengine/filters.py:58 -#: flask_appbuilder/models/sqla/filters.py:22 +#: flask_appbuilder/models/sqla/filters.py:68 msgid "Not Starts with" msgstr "Beginnt nicht mit" #: flask_appbuilder/models/mongoengine/filters.py:82 -#: flask_appbuilder/models/sqla/filters.py:91 +#: flask_appbuilder/models/sqla/filters.py:144 msgid "Relation" msgstr "Beziehung" #: flask_appbuilder/models/mongoengine/filters.py:91 -#: flask_appbuilder/models/sqla/filters.py:107 +#: flask_appbuilder/models/sqla/filters.py:162 msgid "Relation as Many" msgstr "Beziehung als viele" -#: flask_appbuilder/models/sqla/filters.py:29 +#: flask_appbuilder/models/sqla/filters.py:76 msgid "Ends with" msgstr "Endet mit" -#: flask_appbuilder/models/sqla/filters.py:36 +#: flask_appbuilder/models/sqla/filters.py:84 msgid "Not Ends with" msgstr "Endet nicht mit" -#: flask_appbuilder/models/sqla/filters.py:99 +#: flask_appbuilder/models/sqla/filters.py:153 msgid "No Relation" msgstr "Keine Beziehung" @@ -189,9 +196,9 @@ msgid "OpenID" msgstr "openID" #: flask_appbuilder/security/forms.py:11 flask_appbuilder/security/forms.py:16 -#: flask_appbuilder/security/forms.py:33 flask_appbuilder/security/forms.py:50 -#: flask_appbuilder/security/views.py:103 -#: flask_appbuilder/security/views.py:268 +#: flask_appbuilder/security/forms.py:40 flask_appbuilder/security/forms.py:57 +#: flask_appbuilder/security/views.py:125 +#: flask_appbuilder/security/views.py:298 msgid "User Name" msgstr "Benutzername" @@ -199,17 +206,43 @@ msgstr "Benutzername" msgid "Remember me" msgstr "Erinnere an mich" -#: flask_appbuilder/security/forms.py:17 flask_appbuilder/security/forms.py:21 -#: flask_appbuilder/security/forms.py:37 flask_appbuilder/security/views.py:104 -#: flask_appbuilder/security/views.py:207 -#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:31 +#: flask_appbuilder/security/forms.py:17 flask_appbuilder/security/forms.py:28 +#: flask_appbuilder/security/forms.py:44 flask_appbuilder/security/views.py:126 +#: flask_appbuilder/security/views.py:233 +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:30 #: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:27 msgid "Password" msgstr "Passwort" -#: flask_appbuilder/security/forms.py:22 flask_appbuilder/security/forms.py:38 -#: flask_appbuilder/security/views.py:120 -#: flask_appbuilder/security/views.py:208 +#: examples/extendsecurity/app/sec_forms.py:10 +#: examples/extendsecurity2/app/sec_forms.py:10 +#: flask_appbuilder/security/forms.py:21 flask_appbuilder/security/forms.py:41 +#: flask_appbuilder/security/forms.py:58 flask_appbuilder/security/views.py:123 +msgid "First Name" +msgstr "Vorname" + +#: examples/extendsecurity/app/sec_forms.py:11 +#: examples/extendsecurity2/app/sec_forms.py:11 +#: flask_appbuilder/security/forms.py:22 flask_appbuilder/security/views.py:138 +msgid "Write the user first name or names" +msgstr "Geben Sie den ersten oder mehrere Vornamen ein" + +#: examples/extendsecurity/app/sec_forms.py:12 +#: examples/extendsecurity2/app/sec_forms.py:12 +#: flask_appbuilder/security/forms.py:23 flask_appbuilder/security/forms.py:42 +#: flask_appbuilder/security/forms.py:59 flask_appbuilder/security/views.py:124 +msgid "Last Name" +msgstr "Nachname" + +#: examples/extendsecurity/app/sec_forms.py:13 +#: examples/extendsecurity2/app/sec_forms.py:13 +#: flask_appbuilder/security/forms.py:24 flask_appbuilder/security/views.py:139 +msgid "Write the user last name" +msgstr "Geben Sie den Nachnamen des Benutzers ein" + +#: flask_appbuilder/security/forms.py:29 flask_appbuilder/security/forms.py:45 +#: flask_appbuilder/security/views.py:142 +#: flask_appbuilder/security/views.py:234 msgid "" "Please use a good password policy, this application does not check this " "for you" @@ -217,87 +250,78 @@ msgstr "" "Bitte benutzen Sie eine gute Passwortrichtlinie, diese Anwendung " "überprüft das nicht für Sie" -#: flask_appbuilder/security/forms.py:26 flask_appbuilder/security/forms.py:42 -#: flask_appbuilder/security/views.py:212 +#: flask_appbuilder/security/forms.py:33 flask_appbuilder/security/forms.py:49 +#: flask_appbuilder/security/views.py:238 msgid "Confirm Password" msgstr "Passwort bestätigen" -#: flask_appbuilder/security/forms.py:27 flask_appbuilder/security/forms.py:43 +#: flask_appbuilder/security/forms.py:34 flask_appbuilder/security/forms.py:50 msgid "Please rewrite the password to confirm" msgstr "Bitte wiederholen Sie das Passwort zur Bestätigung" -#: flask_appbuilder/security/forms.py:28 flask_appbuilder/security/forms.py:44 -#: flask_appbuilder/security/views.py:215 +#: flask_appbuilder/security/forms.py:35 flask_appbuilder/security/forms.py:51 +#: flask_appbuilder/security/views.py:241 msgid "Passwords must match" msgstr "Passwörter müssen übereinstimmen" -#: flask_appbuilder/security/forms.py:34 flask_appbuilder/security/forms.py:51 -#: flask_appbuilder/security/views.py:101 -msgid "First Name" -msgstr "Vorname" - -#: flask_appbuilder/security/forms.py:35 flask_appbuilder/security/forms.py:52 -#: flask_appbuilder/security/views.py:102 -msgid "Last Name" -msgstr "Nachname" - -#: flask_appbuilder/security/forms.py:36 flask_appbuilder/security/forms.py:53 +#: flask_appbuilder/security/forms.py:43 flask_appbuilder/security/forms.py:60 +#: flask_appbuilder/security/views.py:128 msgid "Email" msgstr "Email" -#: flask_appbuilder/security/manager.py:417 -#: flask_appbuilder/security/views.py:95 +#: flask_appbuilder/security/manager.py:487 +#: flask_appbuilder/security/views.py:117 msgid "List Users" msgstr "Benutzer auflisten" -#: flask_appbuilder/security/manager.py:419 +#: flask_appbuilder/security/manager.py:489 msgid "Security" msgstr "Sicherheit" -#: flask_appbuilder/security/manager.py:422 -#: flask_appbuilder/security/views.py:293 +#: flask_appbuilder/security/manager.py:492 +#: flask_appbuilder/security/views.py:323 msgid "List Roles" msgstr "Rollen auflisten" -#: flask_appbuilder/security/manager.py:428 +#: flask_appbuilder/security/manager.py:498 msgid "User's Statistics" msgstr "Benutzerstatistik" -#: flask_appbuilder/security/manager.py:434 +#: flask_appbuilder/security/manager.py:504 msgid "User Registrations" -msgstr "Ihre Benutzerinformationen" +msgstr "Benutzerregistrierungen" -#: flask_appbuilder/security/manager.py:440 +#: flask_appbuilder/security/manager.py:510 msgid "Base Permissions" msgstr "Grundberechtigungen" -#: flask_appbuilder/security/manager.py:443 +#: flask_appbuilder/security/manager.py:513 msgid "Views/Menus" msgstr "Ansichten/Menüs" -#: flask_appbuilder/security/manager.py:446 +#: flask_appbuilder/security/manager.py:516 msgid "Permission on Views/Menus" msgstr "Berechtigung auf Ansichten/Menüs" -#: flask_appbuilder/security/registerviews.py:55 +#: flask_appbuilder/security/registerviews.py:51 msgid "Account activation" msgstr "Kontoaktivierung" -#: flask_appbuilder/security/registerviews.py:59 +#: flask_appbuilder/security/registerviews.py:55 msgid "Registration sent to your email" msgstr "Registrierung an Ihre E-Mail-Adresse gesendet" -#: flask_appbuilder/security/registerviews.py:61 +#: flask_appbuilder/security/registerviews.py:57 msgid "Not possible to register you at the moment, try again later" msgstr "" "Nicht möglich ist, die Sie im Moment zu registrieren, versuchen Sie es " "später noch einmal" -#: flask_appbuilder/security/registerviews.py:63 +#: flask_appbuilder/security/registerviews.py:59 msgid "Registration not found" msgstr "Registrierung nicht gefunden" -#: flask_appbuilder/security/registerviews.py:65 +#: flask_appbuilder/security/registerviews.py:61 msgid "Fill out the registration form" msgstr "Füllen Sie das Anmeldeformular aus" @@ -318,7 +342,7 @@ msgid "Edit Base Permission" msgstr "Grundberechtigungen bearbeiten" #: flask_appbuilder/security/views.py:33 flask_appbuilder/security/views.py:45 -#: flask_appbuilder/security/views.py:298 +#: flask_appbuilder/security/views.py:328 msgid "Name" msgstr "Name" @@ -370,87 +394,88 @@ msgstr "Passwort zurücksetzen Formular" msgid "Password Changed" msgstr "Passwort geändert" +#: flask_appbuilder/security/views.py:94 +msgid "Edit User Information" +msgstr "Benutzerinformationen bearbeiten" + #: flask_appbuilder/security/views.py:96 +msgid "User information changed" +msgstr "Benutzerinformationen geändert" + +#: flask_appbuilder/security/views.py:118 msgid "Show User" msgstr "Benutzer anzeigen" -#: flask_appbuilder/security/views.py:97 +#: flask_appbuilder/security/views.py:119 msgid "Add User" msgstr "Benutzer hinzufügen" -#: flask_appbuilder/security/views.py:98 +#: flask_appbuilder/security/views.py:120 +#: flask_appbuilder/security/views.py:186 msgid "Edit User" msgstr "Benutzer bearbeiten" -#: flask_appbuilder/security/views.py:100 +#: flask_appbuilder/security/views.py:122 msgid "Full Name" msgstr "Vollständiger Name" -#: flask_appbuilder/security/views.py:105 +#: flask_appbuilder/security/views.py:127 msgid "Is Active?" msgstr "Ist aktiv?" -#: flask_appbuilder/security/views.py:107 +#: flask_appbuilder/security/views.py:129 msgid "Role" msgstr "Rolle" -#: flask_appbuilder/security/views.py:108 +#: flask_appbuilder/security/views.py:130 msgid "Last login" msgstr "Letzter Login" -#: flask_appbuilder/security/views.py:109 -#: flask_appbuilder/security/views.py:269 +#: flask_appbuilder/security/views.py:131 +#: flask_appbuilder/security/views.py:299 msgid "Login count" msgstr "Anzahl Logins" -#: flask_appbuilder/security/views.py:110 -#: flask_appbuilder/security/views.py:270 +#: flask_appbuilder/security/views.py:132 +#: flask_appbuilder/security/views.py:300 msgid "Failed login count" msgstr "Fehlgeschlagene Logins" -#: flask_appbuilder/security/views.py:111 +#: flask_appbuilder/security/views.py:133 msgid "Created on" msgstr "Erstellt am" -#: flask_appbuilder/security/views.py:112 +#: flask_appbuilder/security/views.py:134 msgid "Created by" msgstr "Erstellt von" -#: flask_appbuilder/security/views.py:113 +#: flask_appbuilder/security/views.py:135 msgid "Changed on" msgstr "Geändert am" -#: flask_appbuilder/security/views.py:114 +#: flask_appbuilder/security/views.py:136 msgid "Changed by" msgstr "Geändert von" -#: flask_appbuilder/security/views.py:116 -msgid "Write the user first name or names" -msgstr "Geben Sie den ersten oder mehrere Benutzernamen ein" - -#: flask_appbuilder/security/views.py:117 -msgid "Write the user last name" -msgstr "Geben Sie den Nachnamen des Benutzers ein" - -#: flask_appbuilder/security/views.py:118 +#: flask_appbuilder/security/views.py:140 msgid "Username valid for authentication on DB or LDAP, unused for OID auth" msgstr "" "Login gültig für die Authentifizierung auf DB oder LDAP, nicht für OID " "benutzt" -#: flask_appbuilder/security/views.py:122 +#: flask_appbuilder/security/views.py:144 msgid "It's not a good policy to remove a user, just make it inactive" msgstr "" "Es ist keine gute Richtlinie, einen Benutzer zu entfernen, setzen Sie ihn" " einfach inaktiv" -#: flask_appbuilder/security/views.py:123 +#: flask_appbuilder/security/views.py:145 msgid "The user's email, this will also be used for OID auth" msgstr "" "Die E-Mail des Benutzers, wird ebenfalls für die OID-Authentifizierung " "verwendet werden" -#: flask_appbuilder/security/views.py:124 +#: flask_appbuilder/security/views.py:146 msgid "" "The user role on the application, this will associate with a list of " "permissions" @@ -458,78 +483,103 @@ msgstr "" "Die Benutzerrolle auf der Anwendung, diese wird mit einer Liste von " "Berechtigungen verknüpft" -#: flask_appbuilder/security/views.py:126 -#: flask_appbuilder/security/views.py:213 +#: flask_appbuilder/security/views.py:148 +#: flask_appbuilder/security/views.py:239 msgid "Please rewrite the user's password to confirm" msgstr "Wiederholen Sie bitte das Passwort des Benutzers zur Bestätigung" -#: flask_appbuilder/security/views.py:131 -#: flask_appbuilder/security/views.py:141 +#: examples/extendsecurity/app/sec_views.py:12 +#: examples/extendsecurity/app/sec_views.py:22 +#: examples/extendsecurity2/app/sec_views.py:12 +#: examples/extendsecurity2/app/sec_views.py:22 +#: examples/issue_169/app/sec_views.py:12 +#: examples/issue_169/app/sec_views.py:22 +#: examples/mongo_extendedsecurity/app/mysecurity.py:28 +#: examples/mongo_extendedsecurity/app/mysecurity.py:38 +#: examples/quickhowto2/app/sec_views.py:13 +#: examples/quickhowto2/app/sec_views.py:23 +#: flask_appbuilder/security/views.py:153 +#: flask_appbuilder/security/views.py:163 msgid "User info" msgstr "Benutzerinfo" -#: flask_appbuilder/security/views.py:133 -#: flask_appbuilder/security/views.py:143 +#: examples/extendsecurity/app/sec_views.py:14 +#: examples/extendsecurity/app/sec_views.py:24 +#: examples/extendsecurity2/app/sec_views.py:14 +#: examples/extendsecurity2/app/sec_views.py:24 +#: examples/issue_169/app/sec_views.py:14 +#: examples/issue_169/app/sec_views.py:24 +#: examples/mongo_extendedsecurity/app/mysecurity.py:30 +#: examples/mongo_extendedsecurity/app/mysecurity.py:40 +#: examples/quickhowto2/app/sec_views.py:15 +#: examples/quickhowto2/app/sec_views.py:25 +#: flask_appbuilder/security/views.py:155 +#: flask_appbuilder/security/views.py:165 msgid "Personal Info" msgstr "Persönliche Infos" -#: flask_appbuilder/security/views.py:135 +#: examples/extendsecurity/app/sec_views.py:16 +#: examples/extendsecurity2/app/sec_views.py:16 +#: examples/issue_169/app/sec_views.py:16 +#: examples/mongo_extendedsecurity/app/mysecurity.py:32 +#: examples/quickhowto2/app/sec_views.py:17 +#: flask_appbuilder/security/views.py:157 msgid "Audit Info" msgstr "Audit Info" -#: flask_appbuilder/security/views.py:151 +#: flask_appbuilder/security/views.py:173 msgid "Your user information" msgstr "Ihre Benutzerinformationen" -#: flask_appbuilder/security/views.py:250 +#: flask_appbuilder/security/views.py:280 msgid "Reset my password" msgstr "Mein Passwort zurücksetzen" -#: flask_appbuilder/security/views.py:254 +#: flask_appbuilder/security/views.py:284 msgid "Reset Password" msgstr "Passwort zurücksetzen" -#: flask_appbuilder/security/views.py:267 +#: flask_appbuilder/security/views.py:297 msgid "User Statistics" msgstr "Benutzerstatistik" -#: flask_appbuilder/security/views.py:294 +#: flask_appbuilder/security/views.py:324 msgid "Show Role" msgstr "Rolle anzeigen" -#: flask_appbuilder/security/views.py:295 +#: flask_appbuilder/security/views.py:325 msgid "Add Role" msgstr "Neue Rolle" -#: flask_appbuilder/security/views.py:296 +#: flask_appbuilder/security/views.py:326 msgid "Edit Role" msgstr "Rolle bearbeiten" -#: flask_appbuilder/security/views.py:298 +#: flask_appbuilder/security/views.py:328 msgid "Permissions" msgstr "Berechtigungen" -#: flask_appbuilder/security/views.py:302 +#: flask_appbuilder/security/views.py:332 msgid "Copy Role" msgstr "Copy Rolle" -#: flask_appbuilder/security/views.py:302 +#: flask_appbuilder/security/views.py:332 msgid "Copy the selected roles?" msgstr "Kopieren Sie die ausgewählten Rollen?" -#: flask_appbuilder/security/views.py:317 +#: flask_appbuilder/security/views.py:347 msgid "List of Registration Requests" -msgstr "Liste der Registrierungsanforderungen" +msgstr "Liste der Registrierungs-Anfragen" -#: flask_appbuilder/security/views.py:318 +#: flask_appbuilder/security/views.py:348 msgid "Show Registration" msgstr "Show Registration" -#: flask_appbuilder/security/views.py:327 +#: flask_appbuilder/security/views.py:358 msgid "Invalid login. Please try again." msgstr "Ungültige Anmeldedaten. Bitte versuchen Sie es nochmal." -#: flask_appbuilder/security/views.py:329 +#: flask_appbuilder/security/views.py:360 #: flask_appbuilder/templates/appbuilder/general/security/login_db.html:46 #: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:40 #: flask_appbuilder/templates/appbuilder/general/security/login_oauth.html:56 @@ -542,11 +592,13 @@ msgstr "Anmelden" msgid "Profile" msgstr "Profil" +#: examples/quicktemplates/app/templates/mybase.html:42 #: flask_appbuilder/templates/appbuilder/baselib.html:116 #: flask_appbuilder/templates/appbuilder/navbar_right.html:38 msgid "Logout" msgstr "Abmelden" +#: examples/quicktemplates/app/templates/mybase.html:47 #: flask_appbuilder/templates/appbuilder/baselib.html:122 #: flask_appbuilder/templates/appbuilder/navbar_right.html:43 msgid "Login" @@ -565,6 +617,12 @@ msgstr "Benutzerbestätigung erforderlich" msgid "Actions" msgstr "Aktionen" +#: examples/issue_169/app/templates/list_angulajs.html:55 +#: examples/issue_169/app/templates/list_angulajs.html:105 +#: examples/issue_169/app/templates/list_angulajs.html:184 +#: examples/quickhowto2/app/templates/list_angulajs.html:55 +#: examples/quickhowto2/app/templates/list_angulajs.html:105 +#: examples/quickhowto2/app/templates/list_angulajs.html:184 #: flask_appbuilder/templates/appbuilder/general/lib.html:65 msgid "Page size" msgstr "Seitengröße" @@ -573,27 +631,57 @@ msgstr "Seitengröße" msgid "Order by" msgstr "" -#: flask_appbuilder/templates/appbuilder/general/lib.html:282 +#: examples/issue_169/app/templates/list_angulajs.html:54 +#: examples/issue_169/app/templates/list_angulajs.html:110 +#: examples/issue_169/app/templates/list_angulajs.html:189 +#: examples/quickhowto2/app/templates/list_angulajs.html:54 +#: examples/quickhowto2/app/templates/list_angulajs.html:110 +#: examples/quickhowto2/app/templates/list_angulajs.html:189 +#: flask_appbuilder/templates/appbuilder/general/lib.html:280 msgid "Record Count" msgstr "Anzahl Datensätze" -#: flask_appbuilder/templates/appbuilder/general/lib.html:320 +#: examples/issue_169/app/templates/list_angulajs.html:50 +#: examples/issue_169/app/templates/list_angulajs.html:108 +#: examples/issue_169/app/templates/list_angulajs.html:187 +#: examples/quickhowto2/app/templates/list_angulajs.html:50 +#: examples/quickhowto2/app/templates/list_angulajs.html:108 +#: examples/quickhowto2/app/templates/list_angulajs.html:187 +#: flask_appbuilder/templates/appbuilder/general/lib.html:318 msgid "Add a new record" msgstr "Neuen Eintrag hinzufügen" -#: flask_appbuilder/templates/appbuilder/general/lib.html:327 +#: examples/issue_169/app/templates/list_angulajs.html:52 +#: examples/issue_169/app/templates/list_angulajs.html:132 +#: examples/issue_169/app/templates/list_angulajs.html:211 +#: examples/quickhowto2/app/templates/list_angulajs.html:52 +#: examples/quickhowto2/app/templates/list_angulajs.html:132 +#: examples/quickhowto2/app/templates/list_angulajs.html:211 +#: flask_appbuilder/templates/appbuilder/general/lib.html:325 msgid "Edit record" msgstr "Eintrag bearbeiten" -#: flask_appbuilder/templates/appbuilder/general/lib.html:334 +#: examples/issue_169/app/templates/list_angulajs.html:51 +#: examples/issue_169/app/templates/list_angulajs.html:131 +#: examples/issue_169/app/templates/list_angulajs.html:210 +#: examples/quickhowto2/app/templates/list_angulajs.html:51 +#: examples/quickhowto2/app/templates/list_angulajs.html:131 +#: examples/quickhowto2/app/templates/list_angulajs.html:210 +#: flask_appbuilder/templates/appbuilder/general/lib.html:332 msgid "Show record" msgstr "Eintrag anzeigen" -#: flask_appbuilder/templates/appbuilder/general/lib.html:340 +#: flask_appbuilder/templates/appbuilder/general/lib.html:338 msgid "You sure you want to delete this item?" msgstr "Sie sicher, dass Sie dieses Item löschen möchten?" -#: flask_appbuilder/templates/appbuilder/general/lib.html:341 +#: examples/issue_169/app/templates/list_angulajs.html:53 +#: examples/issue_169/app/templates/list_angulajs.html:133 +#: examples/issue_169/app/templates/list_angulajs.html:212 +#: examples/quickhowto2/app/templates/list_angulajs.html:53 +#: examples/quickhowto2/app/templates/list_angulajs.html:133 +#: examples/quickhowto2/app/templates/list_angulajs.html:212 +#: flask_appbuilder/templates/appbuilder/general/lib.html:339 msgid "Delete record" msgstr "Eintrag löschen" @@ -601,16 +689,21 @@ msgstr "Eintrag löschen" msgid "Group by fields" msgstr "Gruppieren nach Feldern" +#: flask_appbuilder/templates/appbuilder/general/model/edit.html:9 +#: flask_appbuilder/templates/appbuilder/general/model/show.html:9 +msgid "Detail" +msgstr "" + #: flask_appbuilder/templates/appbuilder/general/security/activation.html:7 msgid "Your user is activated you can now proceed to login" msgstr "Ihr Benutzername aktiviert ist, können Sie nun zur anmelden" -#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:19 +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:18 #: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:16 msgid "Enter your login and password below" msgstr "Geben Sie Ihren Benutzernamen und Ihr Passwort ein" -#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:21 +#: flask_appbuilder/templates/appbuilder/general/security/login_db.html:20 #: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:18 msgid "Username" msgstr "Benutzername" @@ -619,7 +712,7 @@ msgstr "Benutzername" #: flask_appbuilder/templates/appbuilder/general/security/login_oauth.html:59 #: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:121 msgid "If you are not already a user, please register" -msgstr "" +msgstr "Wenn Sie noch kein Nutzer sind, registrieren Sie sich bitte" #: flask_appbuilder/templates/appbuilder/general/security/login_db.html:50 #: flask_appbuilder/templates/appbuilder/general/security/login_oauth.html:60 @@ -641,7 +734,7 @@ msgstr "Oder geben Sie hier Ihre OpenID ein" #: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:105 msgid "Please choose a provider" -msgstr "" +msgstr "Bitte wählen sie einen Anbieter" #: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:107 msgid "Enter your OpenID Username" @@ -649,9 +742,60 @@ msgstr "Oder geben Sie hier Ihre OpenID ein" #: flask_appbuilder/templates/appbuilder/general/security/register_oauth.html:15 msgid "Sign in using:" -msgstr "" +msgstr "Anmelden mit:" +#: examples/issue_169/app/templates/list_angulajs.html:67 +#: examples/issue_169/app/templates/list_angulajs.html:149 +#: examples/quickhowto2/app/templates/list_angulajs.html:67 +#: examples/quickhowto2/app/templates/list_angulajs.html:149 #: flask_appbuilder/templates/appbuilder/general/widgets/search.html:7 msgid "Add Filter" msgstr "Filter hinzufügen" +#: examples/extendsecurity/app/sec_forms.py:14 +#: examples/extendsecurity2/app/sec_forms.py:14 +msgid "Emp. Number" +msgstr "MA-Nummer" + +#: examples/extendsecurity/app/sec_forms.py:15 +#: examples/extendsecurity2/app/sec_forms.py:15 +msgid "Employee Number" +msgstr "Mitarbeiter-Nummer" + +#: examples/issue_169/app/forms.py:9 examples/issue_169/app/forms.py:10 +#: examples/quickhowto2/app/forms.py:9 examples/quickhowto2/app/forms.py:10 +msgid "Test Field One" +msgstr "Testfeld Eins" + +#: examples/masterdetail/app/views.py:62 examples/quickhowto3/app/views.py:69 +msgid "List Groups" +msgstr "Gruppen auflisten" + +#: examples/masterdetail/app/views.py:63 +msgid "Manage Groups" +msgstr "Gruppen verwalten" + +#: examples/masterdetail/app/views.py:64 examples/quickhowto3/app/views.py:70 +msgid "List Contacts" +msgstr "Kontakte auflisten" + +#: examples/masterdetail/app/views.py:65 examples/quickhowto3/app/views.py:71 +msgid "Contacts Chart" +msgstr "Diagramme Kontakte" + +#: examples/masterdetail/app/views.py:66 examples/quickhowto3/app/views.py:72 +msgid "Contacts Birth Chart" +msgstr "Diagram Kotaktgeburtstage" + +#: examples/oauth/app/forms.py:9 +msgid "Tweet message" +msgstr "Nachricht twittern" + +#: examples/simpleform/app/views.py:20 +msgid "My form View" +msgstr "Meine Formularanzeige" + +#: examples/simpleform/app/templates/404.html:4 +msgid "Page not found" +msgstr "Seite nicht gefunden" + From 411f72d9eed337f850adc3089cf3b268a609ac61 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Fri, 22 Apr 2022 10:28:42 +0100 Subject: [PATCH 010/113] chore: bump postgres to 14 (#1833) --- .github/workflows/ci.yml | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 643b95cd45..1afff17d9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: postgresql+psycopg2://pguser:pguserpassword@127.0.0.1:15432/app services: postgres: - image: postgres:10-alpine + image: postgres:14-alpine env: POSTGRES_USER: pguser POSTGRES_PASSWORD: pguserpassword diff --git a/docker-compose.yml b/docker-compose.yml index bba4582687..e6b1435425 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.7" services: postgres: container_name: fab-postgres - image: postgres:10 + image: postgres:14 restart: unless-stopped env_file: .env command: postgres -c 'max_connections=500' From c34e9210cd514916092bb350ed2e61b2d2433c3a Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Fri, 22 Apr 2022 10:45:36 +0100 Subject: [PATCH 011/113] fix: noop user update on Auth db, use set user model (#1834) --- flask_appbuilder/security/sqla/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index aef28d1650..84f84fa80c 100755 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -255,7 +255,7 @@ def get_first_user(self) -> "User": def noop_user_update(self, user: "User") -> None: stmt = ( - update(User) + update(self.user_model) .where(self.user_model.id == user.id) .values(login_count=user.login_count) ) From cb9c9855a225a34f5e1d2a5acb3c53b68c77833c Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 28 Apr 2022 14:52:40 +0100 Subject: [PATCH 012/113] fix: dependency constraints, bump flask-login, flask-wtf (#1838) * fix: dependency constraints * bump flask-login --- requirements.txt | 9 +++++++-- setup.py | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index de59a778cf..bd420f7e42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,11 +32,11 @@ flask-babel==2.0.0 # via Flask-AppBuilder (setup.py) flask-jwt-extended==4.3.1 # via Flask-AppBuilder (setup.py) -flask-login==0.4.1 +flask-login==0.6.0 # via Flask-AppBuilder (setup.py) flask-sqlalchemy==2.5.1 # via Flask-AppBuilder (setup.py) -flask-wtf==0.14.3 +flask-wtf==0.15.1 # via Flask-AppBuilder (setup.py) greenlet==1.1.2 # via sqlalchemy @@ -65,12 +65,16 @@ marshmallow-enum==1.5.1 # via Flask-AppBuilder (setup.py) marshmallow-sqlalchemy==0.26.1 # via Flask-AppBuilder (setup.py) +packaging==21.3 + # via marshmallow prison==0.2.1 # via Flask-AppBuilder (setup.py) pyjwt==2.3.0 # via # Flask-AppBuilder (setup.py) # flask-jwt-extended +pyparsing==3.0.8 + # via packaging pyrsistent==0.18.1 # via jsonschema python-dateutil==2.8.2 @@ -99,6 +103,7 @@ werkzeug==2.0.3 # via # flask # flask-jwt-extended + # flask-login wtforms==2.3.3 # via # Flask-AppBuilder (setup.py) diff --git a/setup.py b/setup.py index 9993dc5db3..3ee95fba06 100644 --- a/setup.py +++ b/setup.py @@ -51,9 +51,9 @@ def desc(): "email_validator>=1.0.5, <2", "Flask>=2, <3", "Flask-Babel>=1, <3", - "Flask-Login>=0.3, <0.5", + "Flask-Login>=0.3, <0.7", "Flask-SQLAlchemy>=2.4, <3", - "Flask-WTF>=0.14.2, <0.15.0", + "Flask-WTF>=0.14.2, <1.0.0", "Flask-JWT-Extended>=4.0.0, <5.0.0", "jsonschema>=3, <5", "marshmallow>=3, <4", From 090ddb4645992fb390da93906301c63b204db0df Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 28 Apr 2022 15:23:42 +0100 Subject: [PATCH 013/113] fix: security api (#1831) * fix: security api * fix tests and add more tests * fix tests * fix tests * fix tests * fix tests * fix tests * fix tests * add type annotations to base * test mypy * fix lint * fix tests * fix mypy * fix mypy --- .github/workflows/ci.yml | 2 +- examples/crud_rest_api/config.py | 1 + flask_appbuilder/api/__init__.py | 10 +- flask_appbuilder/base.py | 483 ++++++++++-------- flask_appbuilder/baseviews.py | 41 +- flask_appbuilder/models/mixins.py | 8 +- flask_appbuilder/security/manager.py | 8 +- .../security/sqla/apis/permission/api.py | 2 +- .../sqla/apis/permission_view_menu/api.py | 5 +- .../security/sqla/apis/role/api.py | 4 +- .../security/sqla/apis/user/api.py | 18 +- .../security/sqla/apis/user/schema.py | 2 + .../security/sqla/apis/view_menu/api.py | 4 +- flask_appbuilder/security/sqla/manager.py | 4 +- flask_appbuilder/security/sqla/models.py | 8 +- .../tests/A_fixture/test_0_fixture.py | 26 +- flask_appbuilder/tests/base.py | 16 +- flask_appbuilder/tests/config_security_api.py | 22 + .../tests/security/test_mvc_security.py | 9 + flask_appbuilder/tests/test_api.py | 21 +- flask_appbuilder/tests/test_mvc.py | 26 +- flask_appbuilder/tests/test_mvc_oauth.py | 11 + flask_appbuilder/tests/test_security_api.py | 405 ++++++++++----- requirements-dev.txt | 1 + setup.cfg | 7 + 25 files changed, 722 insertions(+), 422 deletions(-) create mode 100644 flask_appbuilder/tests/config_security_api.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1afff17d9b..7a653bb475 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: [3.7] + python-version: [3.8] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/examples/crud_rest_api/config.py b/examples/crud_rest_api/config.py index f73635595a..ea23ce46b8 100644 --- a/examples/crud_rest_api/config.py +++ b/examples/crud_rest_api/config.py @@ -2,6 +2,7 @@ basedir = os.path.abspath(os.path.dirname(__file__)) +FAB_ADD_SECURITY_API = True CSRF_ENABLED = True SECRET_KEY = "\2\1thisismyscretkey\1\2\e\y\y\h" diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 54e0d1d662..71cd5743f4 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -21,6 +21,7 @@ from .convert import Model2SchemaConverter from .schemas import get_info_schema, get_item_schema, get_list_schema from .._compat import as_unicode +from ..baseviews import AbstractViewApi from ..const import ( API_ADD_COLUMNS_RES_KEY, API_ADD_COLUMNS_RIS_KEY, @@ -203,7 +204,7 @@ def wrap(f): return wrap -class BaseApi(object): +class BaseApi(AbstractViewApi): """ All apis inherit from this class. it's constructor will register your exposed urls on flask @@ -213,8 +214,6 @@ class BaseApi(object): but provides a common base for all APIS. """ - appbuilder = None - blueprint = None endpoint: Optional[str] = None version: Optional[str] = "v1" @@ -436,6 +435,9 @@ def __init__(self) -> None: Initialization of extra args """ + self.appbuilder = None + self.blueprint = None + # Init OpenAPI self._response_key_func_mappings = dict() self.apispec_parameter_schemas = self.apispec_parameter_schemas or dict() @@ -652,7 +654,7 @@ def get_uninit_inner_views(self): """ return [] - def get_init_inner_views(self, views): + def get_init_inner_views(self): """ Sets initialized inner views """ diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 78529bcace..485e3cccb6 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -1,8 +1,9 @@ from functools import reduce import logging -from typing import Dict +from typing import Any, Callable, cast, Dict, List, Optional, Type, TYPE_CHECKING, Union -from flask import Blueprint, current_app, url_for +from flask import Blueprint, current_app, Flask, url_for +from sqlalchemy.orm.session import Session as SessionBase from . import __version__ from .api.manager import OpenApiManager @@ -20,14 +21,24 @@ from .menu import Menu, MenuApiManager from .views import IndexView, UtilView +if TYPE_CHECKING: + from flask_appbuilder.basemanager import BaseManager + from flask_appbuilder.baseviews import BaseView, AbstractViewApi + from flask_appbuilder.security.manager import BaseSecurityManager + log = logging.getLogger(__name__) -def dynamic_class_import(class_path): +DynamicImportType = Union[ + Type["BaseManager"], Type["BaseView"], Type["BaseSecurityManager"], Type[Menu] +] + + +def dynamic_class_import(class_path: str) -> Optional[DynamicImportType]: """ - Will dynamically import a class from a string path - :param class_path: string with class path - :return: class + Will dynamically import a class from a string path + :param class_path: string with class path + :return: class """ # Split first occurrence of path try: @@ -38,103 +49,84 @@ def dynamic_class_import(class_path): except Exception as e: log.exception(e) log.error(LOGMSG_ERR_FAB_ADDON_IMPORT.format(class_path, e)) + return None -class AppBuilder(object): +class AppBuilder: """ + This is the base class for all the framework. + This is were you will register all your views + and create the menu structure. + Will hold your flask app object, all your views, and security classes. + initialize your application like this for SQLAlchemy:: - This is the base class for all the framework. - This is were you will register all your views - and create the menu structure. - Will hold your flask app object, all your views, and security classes. - - initialize your application like this for SQLAlchemy:: - - from flask import Flask - from flask_appbuilder import SQLA, AppBuilder + from flask import Flask + from flask_appbuilder import SQLA, AppBuilder - app = Flask(__name__) - app.config.from_object('config') - db = SQLA(app) - appbuilder = AppBuilder(app, db.session) + app = Flask(__name__) + app.config.from_object('config') + db = SQLA(app) + appbuilder = AppBuilder(app, db.session) - When using MongoEngine:: + When using MongoEngine:: - from flask import Flask - from flask_appbuilder import AppBuilder - from flask_appbuilder.security.mongoengine.manager import SecurityManager - from flask_mongoengine import MongoEngine + from flask import Flask + from flask_appbuilder import AppBuilder + from flask_appbuilder.security.mongoengine.manager import SecurityManager + from flask_mongoengine import MongoEngine - app = Flask(__name__) - app.config.from_object('config') - dbmongo = MongoEngine(app) - appbuilder = AppBuilder(app, security_manager_class=SecurityManager) + app = Flask(__name__) + app.config.from_object('config') + dbmongo = MongoEngine(app) + appbuilder = AppBuilder(app, security_manager_class=SecurityManager) - You can also create everything as an application factory. + You can also create everything as an application factory. """ - baseviews = [] security_manager_class = None - # Flask app - app = None - # Database Session - session = None - # Security Manager Class - sm = None - # Babel Manager Class - bm = None - # OpenAPI Manager Class - openapi_manager = None - # dict with addon name has key and intantiated class has value - addon_managers = None - # temporary list that hold addon_managers config key - _addon_managers = None - - menu = None - indexview = None - - static_folder = None - static_url_path = None template_filters = None def __init__( self, - app=None, - session=None, - menu=None, - indexview=None, - base_template="appbuilder/baselayout.html", - static_folder="static/appbuilder", - static_url_path="/appbuilder", - security_manager_class=None, - update_perms=True, - ): - """ - AppBuilder constructor - - :param app: - The flask app object - :param session: - The SQLAlchemy session object - :param menu: - optional, a previous contructed menu - :param indexview: - optional, your customized indexview - :param static_folder: - optional, your override for the global static folder - :param static_url_path: - optional, your override for the global static url path - :param security_manager_class: - optional, pass your own security manager class - :param update_perms: - optional, update permissions flag (Boolean) you can use - FAB_UPDATE_PERMS config key also - """ - self.baseviews = [] - self._addon_managers = [] - self.addon_managers = {} + app: Optional[Flask] = None, + session: Optional[SessionBase] = None, + menu: Optional[Menu] = None, + indexview: Optional["AbstractViewApi"] = None, + base_template: str = "appbuilder/baselayout.html", + static_folder: str = "static/appbuilder", + static_url_path: str = "/appbuilder", + security_manager_class: Optional[Type["BaseSecurityManager"]] = None, + update_perms: bool = True, + ) -> None: + """ + AppBuilder init + + :param app: + The flask app object + :param session: + The SQLAlchemy session object + :param menu: + optional, a previous contructed menu + :param indexview: + optional, your customized indexview + :param static_folder: + optional, your override for the global static folder + :param static_url_path: + optional, your override for the global static url path + :param security_manager_class: + optional, pass your own security manager class + :param update_perms: + optional, update permissions flag (Boolean) you can use + FAB_UPDATE_PERMS config key also + """ + self.baseviews: List["AbstractViewApi"] = [] + + # temporary list that hold addon_managers config key + self._addon_managers: List[str] = [] + # dict with addon name has key and instantiated class has value + self.addon_managers: Dict[str, Any] = {} self.menu = menu self.base_template = base_template self.security_manager_class = security_manager_class @@ -144,15 +136,22 @@ def __init__( self.app = app self.update_perms = update_perms + # Security Manager Class + self.sm: BaseSecurityManager = None # type: ignore + # Babel Manager Class + self.bm: BabelManager = None # type: ignore + self.openapi_manager: OpenApiManager = None # type: ignore + self.menuapi_manager: MenuApiManager = None # type: ignore + if app is not None: self.init_app(app, session) - def init_app(self, app, session): + def init_app(self, app: Flask, session: SessionBase) -> None: """ - Will initialize the Flask app, supporting the app factory pattern. + Will initialize the Flask app, supporting the app factory pattern. - :param app: - :param session: The SQLAlchemy session + :param app: + :param session: The SQLAlchemy session """ app.config.setdefault("APP_NAME", "F.A.B.") @@ -174,12 +173,18 @@ def init_app(self, app, session): ) _index_view = app.config.get("FAB_INDEX_VIEW", None) if _index_view is not None: - self.indexview = dynamic_class_import(_index_view) + view = dynamic_class_import(_index_view) + if isinstance(view, BaseView): + self.indexview = view else: - self.indexview = self.indexview or IndexView + self.indexview = self.indexview or IndexView() _menu = app.config.get("FAB_MENU", None) + + # Setup Menu if _menu is not None: - self.menu = dynamic_class_import(_menu) + menu = dynamic_class_import(_menu) + if isinstance(menu, Menu): + self.menu = menu else: self.menu = self.menu or Menu() @@ -189,8 +194,9 @@ def init_app(self, app, session): "FAB_SECURITY_MANAGER_CLASS", None ) if _security_manager_class_name is not None: - self.security_manager_class = dynamic_class_import( - _security_manager_class_name + security_manager_class = dynamic_class_import(_security_manager_class_name) + self.security_manager_class = cast( + Type[BaseSecurityManager], security_manager_class ) if self.security_manager_class is None: from flask_appbuilder.security.sqla.manager import SecurityManager @@ -214,13 +220,13 @@ def init_app(self, app, session): self.post_init() self._init_extension(app) - def _init_extension(self, app): + def _init_extension(self, app: Flask) -> None: app.appbuilder = self if not hasattr(app, "extensions"): app.extensions = {} app.extensions["appbuilder"] = self - def post_init(self): + def post_init(self) -> None: for baseview in self.baseviews: # instantiate the views and add session self._check_and_init(baseview) @@ -231,11 +237,11 @@ def post_init(self): self.add_permissions() @property - def get_app(self): + def get_app(self) -> Flask: """ - Get current or configured flask app + Get current or configured flask app - :return: Flask App + :return: Flask App """ if self.app: return self.app @@ -243,47 +249,47 @@ def get_app(self): return current_app @property - def get_session(self): + def get_session(self) -> SessionBase: """ - Get the current sqlalchemy session. + Get the current sqlalchemy session. - :return: SQLAlchemy Session + :return: SQLAlchemy Session """ return self.session @property - def app_name(self): + def app_name(self) -> str: """ - Get the App name + Get the App name - :return: String with app name + :return: String with app name """ return self.get_app.config["APP_NAME"] @property - def app_theme(self): + def app_theme(self) -> str: """ - Get the App theme name + Get the App theme name - :return: String app theme name + :return: String app theme name """ return self.get_app.config["APP_THEME"] @property - def app_icon(self): + def app_icon(self) -> str: """ - Get the App icon location + Get the App icon location - :return: String with relative app icon location + :return: String with relative app icon location """ return self.get_app.config["APP_ICON"] @property - def languages(self): + def languages(self) -> Dict[str, Any]: return self.get_app.config["LANGUAGES"] @property - def version(self): + def version(self) -> str: """ Get the current F.A.B. version @@ -291,10 +297,10 @@ def version(self): """ return __version__ - def _add_global_filters(self): + def _add_global_filters(self) -> None: self.template_filters = TemplateFilters(self.get_app, self.sm) - def _add_global_static(self): + def _add_global_static(self) -> None: bp = Blueprint( "appbuilder", __name__, @@ -305,59 +311,61 @@ def _add_global_static(self): ) self.get_app.register_blueprint(bp) - def _add_admin_views(self): + def _add_admin_views(self) -> None: """ Registers indexview, utilview (back function), babel views and Security views. """ - self.indexview = self._check_and_init(self.indexview) - self.add_view_no_menu(self.indexview) + if self.indexview: + self.indexview = self._check_and_init(self.indexview) + self.add_view_no_menu(self.indexview) self.add_view_no_menu(UtilView()) self.bm.register_views() self.sm.register_views() self.openapi_manager.register_views() self.menuapi_manager.register_views() - def _add_addon_views(self): + def _add_addon_views(self) -> None: """ - Registers declared addon's + Registers declared addon's """ for addon in self._addon_managers: - addon_class = dynamic_class_import(addon) + addon_class_ = dynamic_class_import(addon) + addon_class = cast(Type[BaseManager], addon_class_) if addon_class: # Instantiate manager with appbuilder (self) - addon_class = addon_class(self) + inst_addon_class: BaseManager = addon_class(self) try: - addon_class.pre_process() - addon_class.register_views() - addon_class.post_process() - self.addon_managers[addon] = addon_class + inst_addon_class.pre_process() + inst_addon_class.register_views() + inst_addon_class.post_process() + self.addon_managers[addon] = inst_addon_class log.info(LOGMSG_INF_FAB_ADDON_ADDED.format(str(addon))) except Exception as e: log.exception(e) log.error(LOGMSG_ERR_FAB_ADDON_PROCESS.format(addon, e)) - def _check_and_init(self, baseview): + def _check_and_init(self, baseview: "AbstractViewApi") -> "AbstractViewApi": # If class if not instantiated, instantiate it # and add db session from security models. if hasattr(baseview, "datamodel"): - if baseview.datamodel.session is None: - baseview.datamodel.session = self.session - if hasattr(baseview, "__call__"): + if getattr(baseview, "datamodel").session is None: + getattr(baseview, "datamodel").session = self.session + if isinstance(baseview, type): baseview = baseview() return baseview def add_view( self, - baseview, - name, - href="", - icon="", - label="", - category="", - category_icon="", - category_label="", - menu_cond=None, - ): + baseview: "AbstractViewApi", + name: str, + href: str = "", + icon: str = "", + label: str = "", + category: str = "", + category_icon: str = "", + category_label: str = "", + menu_cond: Optional[Callable[..., bool]] = None, + ) -> "AbstractViewApi": """ Add your views associated with menus using this method. @@ -450,44 +458,47 @@ def add_view( def add_link( self, - name, - href, - icon="", - label="", - category="", - category_icon="", - category_label="", - baseview=None, - cond=None, - ): - """ - Add your own links to menu using this method - - :param name: - The string name that identifies the menu. - :param href: - Override the generated href for the menu. - You can use an url string or an endpoint name - :param icon: - Font-Awesome icon name, optional. - :param label: - The label that will be displayed on the menu, - if absent param name will be used - :param category: - The menu category where the menu will be included, - if non provided the view will be accessible as a top menu. - :param category_icon: - Font-Awesome icon name for the category, optional. - :param category_label: - The label that will be displayed on the menu, - if absent param name will be used - :param cond: - If a callable, :code:`cond` will be invoked when - constructing the menu items. If it returns :code:`True`, - then this link will be a part of the menu. Otherwise, it - will not be included in the menu items. Defaults to - :code:`None`, meaning the item will always be present. + name: str, + href: str, + icon: str = "", + label: str = "", + category: str = "", + category_icon: str = "", + category_label: str = "", + baseview: Optional["AbstractViewApi"] = None, + cond: Optional[Callable[..., bool]] = None, + ) -> None: + """ + Add your own links to menu using this method + + :param baseview: + :param name: + The string name that identifies the menu. + :param href: + Override the generated href for the menu. + You can use an url string or an endpoint name + :param icon: + Font-Awesome icon name, optional. + :param label: + The label that will be displayed on the menu, + if absent param name will be used + :param category: + The menu category where the menu will be included, + if non provided the view will be accessible as a top menu. + :param category_icon: + Font-Awesome icon name for the category, optional. + :param category_label: + The label that will be displayed on the menu, + if absent param name will be used + :param cond: + If a callable, :code:`cond` will be invoked when + constructing the menu items. If it returns :code:`True`, + then this link will be a part of the menu. Otherwise, it + will not be included in the menu items. Defaults to + :code:`None`, meaning the item will always be present. """ + if self.menu is None: + return self.menu.add_link( name=name, href=href, @@ -504,27 +515,38 @@ def add_link( if category: self._add_permissions_menu(category) - def add_separator(self, category, cond=None): + def add_separator( + self, category: str, cond: Optional[Callable[..., bool]] = None + ) -> None: """ - Add a separator to the menu, you will sequentially create the menu + Add a separator to the menu, you will sequentially create the menu - :param category: - The menu category where the separator will be included. - :param cond: - If a callable, :code:`cond` will be invoked when - constructing the menu items. If it returns :code:`True`, - then this separator will be a part of the menu. Otherwise, - it will not be included in the menu items. Defaults to - :code:`None`, meaning the separator will always be present. + :param category: + The menu category where the separator will be included. + :param cond: + If a callable, :code:`cond` will be invoked when + constructing the menu items. If it returns :code:`True`, + then this separator will be a part of the menu. Otherwise, + it will not be included in the menu items. Defaults to + :code:`None`, meaning the separator will always be present. """ + if self.menu is None: + return self.menu.add_separator(category, cond=cond) - def add_view_no_menu(self, baseview, endpoint=None, static_folder=None): + def add_view_no_menu( + self, + baseview: "AbstractViewApi", + endpoint: Optional[str] = None, + static_folder: Optional[str] = None, + ) -> "AbstractViewApi": """ - Add your views without creating a menu. + Add your views without creating a menu. :param baseview: A BaseView type class instantiated. + :param endpoint: The endpoint path for the Flask blueprint + :param static_folder: The static folder for the Flask blueprint """ baseview = self._check_and_init(baseview) @@ -543,75 +565,89 @@ def add_view_no_menu(self, baseview, endpoint=None, static_folder=None): log.warning(LOGMSG_WAR_FAB_VIEW_EXISTS.format(baseview.__class__.__name__)) return baseview - def add_api(self, baseview): + def add_api(self, baseview: "AbstractViewApi") -> "AbstractViewApi": """ - Add a BaseApi class or child to AppBuilder + Add a BaseApi class or child to AppBuilder :param baseview: A BaseApi type class :return: The instantiated base view """ return self.add_view_no_menu(baseview) - def security_cleanup(self): + def security_cleanup(self) -> None: """ - This method is useful if you have changed - the name of your menus or classes, - changing them will leave behind permissions - that are not associated with anything. + This method is useful if you have changed + the name of your menus or classes, + changing them will leave behind permissions + that are not associated with anything. - You can use it always or just sometimes to - perform a security cleanup. Warning this will delete any permission - that is no longer part of any registered view or menu. + You can use it always or just sometimes to + perform a security cleanup. Warning this will delete any permission + that is no longer part of any registered view or menu. - Remember invoke ONLY AFTER YOU HAVE REGISTERED ALL VIEWS + Remember invoke ONLY AFTER YOU HAVE REGISTERED ALL VIEWS """ self.sm.security_cleanup(self.baseviews, self.menu) - def security_converge(self, dry=False) -> Dict: + def security_converge(self, dry: bool = False) -> Dict[str, Any]: """ - This method is useful when you use: + This method is useful when you use: - - `class_permission_name` - - `previous_class_permission_name` - - `method_permission_name` - - `previous_method_permission_name` + - `class_permission_name` + - `previous_class_permission_name` + - `method_permission_name` + - `previous_method_permission_name` - migrates all permissions to the new names on all the Roles + migrates all permissions to the new names on all the Roles :param dry: If True will not change DB :return: Dict with all computed necessary operations """ - return self.sm.security_converge(self.baseviews, self.menu, dry) + if self.menu is None: + return {} + return self.sm.security_converge(self.baseviews, self.menu.menu, dry) @property - def get_url_for_login(self): + def get_url_for_login(self) -> str: + if self.sm.auth_view is None: + return "" return url_for("%s.%s" % (self.sm.auth_view.endpoint, "login")) @property - def get_url_for_logout(self): + def get_url_for_logout(self) -> str: + if self.sm.auth_view is None: + return "" return url_for("%s.%s" % (self.sm.auth_view.endpoint, "logout")) @property - def get_url_for_index(self): + def get_url_for_index(self) -> str: + if self.indexview is None: + return "" return url_for("%s.%s" % (self.indexview.endpoint, self.indexview.default_view)) @property - def get_url_for_userinfo(self): + def get_url_for_userinfo(self) -> str: + if self.sm.user_view is None: + return "" return url_for("%s.%s" % (self.sm.user_view.endpoint, "userinfo")) - def get_url_for_locale(self, lang): + def get_url_for_locale(self, lang: str) -> str: + if self.bm.locale_view is None: + return "" return url_for( "%s.%s" % (self.bm.locale_view.endpoint, self.bm.locale_view.default_view), locale=lang, ) - def add_permissions(self, update_perms=False): + def add_permissions(self, update_perms: bool = False) -> None: if self.update_perms or update_perms: for baseview in self.baseviews: self._add_permission(baseview, update_perms=update_perms) self._add_menu_permissions(update_perms=update_perms) - def _add_permission(self, baseview, update_perms=False): + def _add_permission( + self, baseview: "AbstractViewApi", update_perms: bool = False + ) -> None: if self.update_perms or update_perms: try: self.sm.add_permissions_view( @@ -621,7 +657,7 @@ def _add_permission(self, baseview, update_perms=False): log.exception(e) log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_VIEW.format(str(e))) - def _add_permissions_menu(self, name, update_perms=False): + def _add_permissions_menu(self, name: str, update_perms: bool = False) -> None: if self.update_perms or update_perms: try: self.sm.add_permissions_menu(name) @@ -629,7 +665,9 @@ def _add_permissions_menu(self, name, update_perms=False): log.exception(e) log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_MENU.format(str(e))) - def _add_menu_permissions(self, update_perms=False): + def _add_menu_permissions(self, update_perms: bool = False) -> None: + if self.menu is None: + return if self.update_perms or update_perms: for category in self.menu.get_list(): self._add_permissions_menu(category.name, update_perms=update_perms) @@ -638,20 +676,25 @@ def _add_menu_permissions(self, update_perms=False): if item.name != "-": self._add_permissions_menu(item.name, update_perms=update_perms) - def register_blueprint(self, baseview, endpoint=None, static_folder=None): + def register_blueprint( + self, + baseview: "AbstractViewApi", + endpoint: Optional[str] = None, + static_folder: Optional[str] = None, + ) -> None: self.get_app.register_blueprint( baseview.create_blueprint( self, endpoint=endpoint, static_folder=static_folder ) ) - def _view_exists(self, view): + def _view_exists(self, view: "AbstractViewApi") -> bool: for baseview in self.baseviews: if baseview.__class__ == view.__class__: return True return False - def _process_inner_views(self): + def _process_inner_views(self) -> None: for view in self.baseviews: for inner_class in view.get_uninit_inner_views(): for v in self.baseviews: diff --git a/flask_appbuilder/baseviews.py b/flask_appbuilder/baseviews.py index 9fb0b16354..3a41f1d217 100644 --- a/flask_appbuilder/baseviews.py +++ b/flask_appbuilder/baseviews.py @@ -3,6 +3,7 @@ import json import logging import re +from typing import List, Optional, TYPE_CHECKING from flask import ( abort, @@ -29,6 +30,10 @@ ) from .widgets import FormWidget, ListWidget, SearchWidget, ShowWidget +if TYPE_CHECKING: + from flask_appbuilder.base import AppBuilder + + log = logging.getLogger(__name__) @@ -65,7 +70,37 @@ def wrap(f): return wrap -class BaseView(object): +class AbstractViewApi: + + appbuilder: "AppBuilder" + base_permissions: Optional[List[str]] + class_permission_name: str + endpoint: str + default_view: str + + def create_blueprint( + self, + appbuilder: "AppBuilder", + endpoint: Optional[str] = None, + static_folder: Optional[str] = None, + ): + ... + + def get_uninit_inner_views(self): + """ + Will return a list with views that need to be initialized. + Normally related_views from ModelView + """ + ... + + def get_init_inner_views(self): + """ + Sets initialized inner views + """ + ... + + +class BaseView(AbstractViewApi): """ All views inherit from this class. it's constructor will register your exposed urls on flask as a Blueprint. @@ -346,9 +381,9 @@ def get_uninit_inner_views(self): """ return [] - def get_init_inner_views(self, views): + def get_init_inner_views(self): """ - Sets initialized inner views + Sets initialized inner views """ pass diff --git a/flask_appbuilder/models/mixins.py b/flask_appbuilder/models/mixins.py index c44f0fec8e..ab6985d05f 100644 --- a/flask_appbuilder/models/mixins.py +++ b/flask_appbuilder/models/mixins.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime import logging from flask import g @@ -46,11 +46,11 @@ class AuditMixin(object): :changed by: """ - created_on = Column(DateTime, default=datetime.datetime.now, nullable=False) + created_on = Column(DateTime, default=lambda: datetime.now(), nullable=False) changed_on = Column( DateTime, - default=datetime.datetime.now, - onupdate=datetime.datetime.now, + default=lambda: datetime.now(), + onupdate=lambda: datetime.now(), nullable=False, ) diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 0f6c14cbf5..34245a5784 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -1681,7 +1681,9 @@ def _update_del_transitions(state_transitions: Dict, baseviews: List) -> None: ) state_transitions["del_perms"].discard(permission) - def create_state_transitions(self, baseviews: List, menus: List) -> Dict: + def create_state_transitions( + self, baseviews: List, menus: Optional[List[Any]] + ) -> Dict: """ Creates a Dict with all the necessary vm/permission transitions @@ -1737,7 +1739,9 @@ def create_state_transitions(self, baseviews: List, menus: List) -> Dict: self._update_del_transitions(state_transitions, baseviews) return state_transitions - def security_converge(self, baseviews: List, menus: List, dry=False) -> Dict: + def security_converge( + self, baseviews: List, menus: Optional[List[Any]], dry=False + ) -> Dict: """ Converges overridden permissions on all registered views/api will compute all necessary operations from `class_permissions_name`, diff --git a/flask_appbuilder/security/sqla/apis/permission/api.py b/flask_appbuilder/security/sqla/apis/permission/api.py index 19c522ce6b..ee76721eea 100644 --- a/flask_appbuilder/security/sqla/apis/permission/api.py +++ b/flask_appbuilder/security/sqla/apis/permission/api.py @@ -4,7 +4,7 @@ class PermissionApi(ModelRestApi): - resource_name = "permissions" + resource_name = "security/permissions" openapi_spec_tag = "Security Permissions" class_permission_name = "Permission" diff --git a/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py b/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py index 61b743881c..9af3d0ff36 100644 --- a/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py +++ b/flask_appbuilder/security/sqla/apis/permission_view_menu/api.py @@ -4,8 +4,8 @@ class PermissionViewMenuApi(ModelRestApi): - resource_name = "permissionsviewmenus" - openapi_spec_tag = "Security Permissions View Menus" + resource_name = "security/permissions-resources" + openapi_spec_tag = "Security Permissions on Resources (View Menus)" class_permission_name = "PermissionViewMenu" datamodel = SQLAInterface(PermissionView) allow_browser_login = True @@ -14,4 +14,3 @@ class PermissionViewMenuApi(ModelRestApi): show_columns = list_columns add_columns = ["permission_id", "view_menu_id"] edit_columns = add_columns - search_columns = list_columns diff --git a/flask_appbuilder/security/sqla/apis/role/api.py b/flask_appbuilder/security/sqla/apis/role/api.py index c5853ddc28..dd69824e03 100644 --- a/flask_appbuilder/security/sqla/apis/role/api.py +++ b/flask_appbuilder/security/sqla/apis/role/api.py @@ -14,7 +14,7 @@ class RoleApi(ModelRestApi): - resource_name = "roles" + resource_name = "security/roles" openapi_spec_tag = "Security Roles" class_permission_name = "Role" datamodel = SQLAInterface(Role) @@ -33,7 +33,7 @@ class RoleApi(ModelRestApi): RolePermissionPostSchema, ) - @expose("//permissions", methods=["GET"]) + @expose("//permissions/", methods=["GET"]) @protect() @safe @permission_name("list_role_permissions") diff --git a/flask_appbuilder/security/sqla/apis/user/api.py b/flask_appbuilder/security/sqla/apis/user/api.py index 6fd3582bac..94f250123d 100644 --- a/flask_appbuilder/security/sqla/apis/user/api.py +++ b/flask_appbuilder/security/sqla/apis/user/api.py @@ -1,6 +1,5 @@ from datetime import datetime -from flask import current_app from flask import g, request from flask_appbuilder import ModelRestApi from flask_appbuilder.api import expose, safe @@ -18,7 +17,7 @@ class UserApi(ModelRestApi): - resource_name = "users" + resource_name = "security/users" openapi_spec_tag = "Security Users" class_permission_name = "User" datamodel = SQLAInterface(User) @@ -52,7 +51,16 @@ class UserApi(ModelRestApi): "password", ] edit_columns = add_columns - search_columns = list_columns + search_columns = [ + "username", + "first_name", + "last_name", + "active", + "email", + "created_by", + "changed_by", + "roles", + ] add_model_schema = UserPostSchema() edit_model_schema = UserPutSchema() @@ -112,7 +120,7 @@ def post(self): else: for role_id in item[key]: role = ( - current_app.appbuilder.get_session.query(Role) + self.datamodel.session.query(Role) .filter(Role.id == role_id) .one_or_none() ) @@ -184,7 +192,7 @@ def put(self, pk): else: for role_id in item[key]: role = ( - current_app.appbuilder.session.query(Role) + self.datamodel.session.query(Role) .filter(Role.id == role_id) .one_or_none() ) diff --git a/flask_appbuilder/security/sqla/apis/user/schema.py b/flask_appbuilder/security/sqla/apis/user/schema.py index df0b7f4c15..ee9e3d33cc 100644 --- a/flask_appbuilder/security/sqla/apis/user/schema.py +++ b/flask_appbuilder/security/sqla/apis/user/schema.py @@ -40,6 +40,8 @@ class UserPostSchema(Schema): class UserPutSchema(Schema): + model_cls = User + active = fields.Boolean(required=False, description=active_description) email = fields.String(required=False, description=email_description) first_name = fields.String(required=False, description=first_name_description) diff --git a/flask_appbuilder/security/sqla/apis/view_menu/api.py b/flask_appbuilder/security/sqla/apis/view_menu/api.py index 3177d645e7..762b1f38ed 100644 --- a/flask_appbuilder/security/sqla/apis/view_menu/api.py +++ b/flask_appbuilder/security/sqla/apis/view_menu/api.py @@ -4,8 +4,8 @@ class ViewMenuApi(ModelRestApi): - resource_name = "viewmenus" - openapi_spec_tag = "Security View Menus" + resource_name = "security/resources" + openapi_spec_tag = "Security Resources (View Menus)" class_permission_name = "ViewMenu" datamodel = SQLAInterface(ViewMenu) diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index 84f84fa80c..bdd43ebebe 100755 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -92,6 +92,8 @@ def get_session(self): return self.appbuilder.get_session def register_views(self): + super(SecurityManager, self).register_views() + if self.appbuilder.app.config.get("FAB_ADD_SECURITY_API", False): self.appbuilder.add_api(self.permission_api) self.appbuilder.add_api(self.role_api) @@ -99,8 +101,6 @@ def register_views(self): self.appbuilder.add_api(self.view_menu_api) self.appbuilder.add_api(self.permission_view_menu_api) - super(SecurityManager, self).register_views() - def create_db(self): try: engine = self.get_session.get_bind(mapper=None, clause=None) diff --git a/flask_appbuilder/security/sqla/models.py b/flask_appbuilder/security/sqla/models.py index 87ffaf5d48..739c8ae644 100755 --- a/flask_appbuilder/security/sqla/models.py +++ b/flask_appbuilder/security/sqla/models.py @@ -104,8 +104,12 @@ class User(Model): login_count = Column(Integer) fail_login_count = Column(Integer) roles = relationship("Role", secondary=assoc_user_role, backref="user") - created_on = Column(DateTime, default=datetime.datetime.now, nullable=True) - changed_on = Column(DateTime, default=datetime.datetime.now, nullable=True) + created_on = Column( + DateTime, default=lambda: datetime.datetime.now(), nullable=True + ) + changed_on = Column( + DateTime, default=lambda: datetime.datetime.now(), nullable=True + ) @declared_attr def created_by_fk(self): diff --git a/flask_appbuilder/tests/A_fixture/test_0_fixture.py b/flask_appbuilder/tests/A_fixture/test_0_fixture.py index b5b24a95b6..7627d2123f 100644 --- a/flask_appbuilder/tests/A_fixture/test_0_fixture.py +++ b/flask_appbuilder/tests/A_fixture/test_0_fixture.py @@ -1,4 +1,5 @@ from flask_appbuilder import SQLA +from freezegun import freeze_time from ..base import FABTestCase from ..const import ( @@ -22,18 +23,21 @@ def setUp(self): self.appbuilder = AppBuilder(self.app, self.db.session) def test_data(self): - insert_data(self.db.session, MODEL1_DATA_SIZE) + with freeze_time("2020-01-01"): + insert_data(self.db.session, MODEL1_DATA_SIZE) def test_create_admin(self): - self.create_admin_user(self.appbuilder, USERNAME_ADMIN, PASSWORD_ADMIN) + with freeze_time("2020-01-01"): + self.create_admin_user(self.appbuilder, USERNAME_ADMIN, PASSWORD_ADMIN) def test_create_ro_user(self): - self.create_user( - self.appbuilder, - USERNAME_READONLY, - PASSWORD_READONLY, - "ReadOnly", - first_name="readonly", - last_name="readonly", - email="readonly@fab.org", - ) + with freeze_time("2020-01-01"): + self.create_user( + self.appbuilder, + USERNAME_READONLY, + PASSWORD_READONLY, + "ReadOnly", + first_name="readonly", + last_name="readonly", + email="readonly@fab.org", + ) diff --git a/flask_appbuilder/tests/base.py b/flask_appbuilder/tests/base.py index 272e33b10a..54dd06d66c 100644 --- a/flask_appbuilder/tests/base.py +++ b/flask_appbuilder/tests/base.py @@ -1,6 +1,6 @@ import json import logging -from typing import Optional, Set +from typing import Any, Dict, List, Optional, Set import unittest from flask import Flask, Response @@ -76,6 +76,20 @@ def browser_login( follow_redirects=follow_redirects, ) + def assert_response( + self, + response: List[Dict[str, Any]], + expected_results: List[Dict[str, Any]], + exclude_cols: Optional[List[str]] = None, + ): + exclude_cols = exclude_cols or [] + for idx, expected_result in enumerate(expected_results): + for field_name, field_value in expected_result.items(): + if field_name not in exclude_cols: + self.assertEqual( + response[idx][field_name], expected_result[field_name] + ) + @staticmethod def browser_logout(client): return client.get("/logout/") diff --git a/flask_appbuilder/tests/config_security_api.py b/flask_appbuilder/tests/config_security_api.py new file mode 100644 index 0000000000..53261d4176 --- /dev/null +++ b/flask_appbuilder/tests/config_security_api.py @@ -0,0 +1,22 @@ +import os + +basedir = os.path.abspath(os.path.dirname(__file__)) + +SQLALCHEMY_DATABASE_URI = ( + os.environ.get("SQLALCHEMY_DATABASE_URI") + or "postgresql+psycopg2://pguser:pguserpassword@127.0.0.1:5432/app" +) + +FAB_ADD_SECURITY_API = True +SECRET_KEY = "thisismyscretkey" +SQLALCHEMY_TRACK_MODIFICATIONS = False +WTF_CSRF_ENABLED = False +FAB_API_SWAGGER_UI = True +FAB_ROLES = { + "ReadOnly": [ + [".*", "can_get"], + [".*", "can_info"], + [".*", "can_list"], + [".*", "can_show"], + ] +} diff --git a/flask_appbuilder/tests/security/test_mvc_security.py b/flask_appbuilder/tests/security/test_mvc_security.py index 9411e88907..a71be5ffa4 100644 --- a/flask_appbuilder/tests/security/test_mvc_security.py +++ b/flask_appbuilder/tests/security/test_mvc_security.py @@ -2,6 +2,7 @@ from flask_appbuilder.exceptions import PasswordComplexityValidationError from flask_appbuilder.models.sqla.filters import FilterEqual from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.sqla.models import User from ..base import BaseMVCTestCase from ..const import PASSWORD_ADMIN, PASSWORD_READONLY, USERNAME_ADMIN, USERNAME_READONLY @@ -388,3 +389,11 @@ def test_register_user(self): self.assertNotIn("Added Row", data) self.assertIn("This field is required", data) self.browser_logout(client) + + user = ( + self.db.session.query(User) + .filter(User.username == "from test 1-1") + .one_or_none() + ) + self.db.session.delete(user) + self.db.session.commit() diff --git a/flask_appbuilder/tests/test_api.py b/flask_appbuilder/tests/test_api.py index aff9795b98..29dbfd24af 100644 --- a/flask_appbuilder/tests/test_api.py +++ b/flask_appbuilder/tests/test_api.py @@ -2733,7 +2733,7 @@ class Model2PermOverride1(ModelRestApi): role = self.appbuilder.sm.add_role("Test") pvm = self.appbuilder.sm.find_permission_view_menu("can_access", "api") self.appbuilder.sm.add_permission_role(role, pvm) - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) @@ -2755,6 +2755,7 @@ class Model2PermOverride1(ModelRestApi): self.appbuilder.sm.find_user(username="test") ) self.appbuilder.get_session.delete(self.appbuilder.sm.find_role("Test")) + self.appbuilder.get_session.delete(user) self.appbuilder.get_session.commit() def test_method_permission_override(self): @@ -2781,7 +2782,7 @@ class Model2PermOverride2(ModelRestApi): "can_read", "Model2PermOverride2" ) self.appbuilder.sm.add_permission_role(role, pvm) - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) @@ -2798,9 +2799,8 @@ class Model2PermOverride2(ModelRestApi): self.assertEqual(rv.status_code, 403) # Revert test data - self.appbuilder.get_session.delete( - self.appbuilder.sm.find_user(username="test") - ) + self.db.session.delete(user) + self.db.session.commit() self.appbuilder.get_session.delete(self.appbuilder.sm.find_role("Test")) self.appbuilder.get_session.commit() @@ -2855,7 +2855,7 @@ class Model1PermConverge(ModelRestApi): role = self.appbuilder.sm.add_role("Test") pvm = self.appbuilder.sm.find_permission_view_menu("can_get", "Model1Api") self.appbuilder.sm.add_permission_role(role, pvm) - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) # Remove previous class, Hack to test code change @@ -2894,9 +2894,7 @@ class Model1PermConverge(ModelRestApi): self.assertEqual(len(role.permissions), 1) # Revert test data - self.appbuilder.get_session.delete( - self.appbuilder.sm.find_user(username="test") - ) + self.appbuilder.get_session.delete(user) self.appbuilder.get_session.delete(self.appbuilder.sm.find_role("Test")) self.appbuilder.get_session.commit() @@ -2930,7 +2928,7 @@ class Model1PermConverge(ModelRestApi): role = self.appbuilder.sm.add_role("Test") pvm = self.appbuilder.sm.find_permission_view_menu("can_access", "api") self.appbuilder.sm.add_permission_role(role, pvm) - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) # Remove previous class, Hack to test code change @@ -2958,6 +2956,9 @@ class Model1PermConverge(ModelRestApi): role = self.appbuilder.sm.find_role("Test") self.assertEqual(len(role.permissions), 5) + self.db.session.delete(user) + self.db.session.commit() + def test_before_request(self): """ REST Api: Test simple before_request filter diff --git a/flask_appbuilder/tests/test_mvc.py b/flask_appbuilder/tests/test_mvc.py index fc63815ae6..2cb6c1508f 100644 --- a/flask_appbuilder/tests/test_mvc.py +++ b/flask_appbuilder/tests/test_mvc.py @@ -1074,7 +1074,7 @@ def test_show_excluded_cols(self): def test_query_rel_fields(self): """ - Test add and edit form related fields filter + Test add and edit form related fields filter """ client = self.app.test_client() self.browser_login(client, USERNAME_ADMIN, PASSWORD_ADMIN) @@ -1437,7 +1437,7 @@ class Model1PermOverride(ModelView): role = self.appbuilder.sm.add_role("Test") pvm = self.appbuilder.sm.find_permission_view_menu("can_access", "view") self.appbuilder.sm.add_permission_role(role, pvm) - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) @@ -1464,6 +1464,10 @@ class Model1PermOverride(ModelView): self.assertEqual(model.field_string, "test1") self.assertEqual(model.field_integer, 1) + # Cleanup + self.db.session.delete(user) + self.db.session.commit() + def test_method_permission_override(self): """ MVC: Test method permission name override @@ -1504,7 +1508,7 @@ class Model1PermOverride(ModelView): self.appbuilder.sm.add_permission_role(role, pvm_read) self.appbuilder.sm.add_permission_role(role, pvm_write) - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) @@ -1571,8 +1575,9 @@ class Model1PermOverride(ModelView): self.assertIn("/model1permoverride/show/1", data) # Revert data changes - self.appbuilder.get_session.delete(self.appbuilder.sm.find_role("Test")) - self.appbuilder.get_session.commit() + self.db.session.delete(self.appbuilder.sm.find_role("Test")) + self.db.session.delete(user) + self.db.session.commit() def test_action_permission_override(self): """ @@ -1611,7 +1616,7 @@ def action_one(self, item): # Add a user and login before enabling CSRF role = self.appbuilder.sm.add_role("Test") - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) pvm_read = self.appbuilder.sm.find_permission_view_menu( @@ -1645,6 +1650,10 @@ def action_one(self, item): rv = client.get("/model1permoverride/action/action1/1") self.assertEqual(rv.status_code, 302) + # cleanup + self.db.session.delete(user) + self.db.session.commit() + def test_permission_converge_compress(self): """ MVC: Test permission name converge compress @@ -1681,7 +1690,7 @@ class Model1PermConverge(ModelView): pvm = self.appbuilder.sm.find_permission_view_menu("can_add", "Model1View") self.appbuilder.sm.add_permission_role(role, pvm) role = self.appbuilder.sm.find_role("Test") - self.appbuilder.sm.add_user( + user = self.appbuilder.sm.add_user( "test", "test", "user", "test@fab.org", role, "test" ) # Remove previous class, Hack to test code change @@ -1714,6 +1723,9 @@ class Model1PermConverge(ModelView): self.assertEqual(state_transitions, target_state_transitions) role = self.appbuilder.sm.find_role("Test") self.assertEqual(len(role.permissions), 1) + # cleanup + self.db.session.delete(user) + self.db.session.commit() def test_before_request(self): """ diff --git a/flask_appbuilder/tests/test_mvc_oauth.py b/flask_appbuilder/tests/test_mvc_oauth.py index 42a7184707..9901edb01e 100644 --- a/flask_appbuilder/tests/test_mvc_oauth.py +++ b/flask_appbuilder/tests/test_mvc_oauth.py @@ -1,4 +1,5 @@ from flask_appbuilder import SQLA +from flask_appbuilder.security.sqla.models import User from flask_appbuilder.tests.base import FABTestCase import jwt @@ -36,6 +37,16 @@ def setUp(self): self.db = SQLA(self.app) self.appbuilder = AppBuilder(self.app, self.db.session) + def tearDown(self): + self.cleanup() + + def cleanup(self): + session = self.appbuilder.get_session + users = session.query(User).filter(User.username.ilike("google%")).all() + for user in users: + session.delete(user) + session.commit() + def test_oauth_login(self): """ OAuth: Test login diff --git a/flask_appbuilder/tests/test_security_api.py b/flask_appbuilder/tests/test_security_api.py index baf0e98a07..63aa8861db 100644 --- a/flask_appbuilder/tests/test_security_api.py +++ b/flask_appbuilder/tests/test_security_api.py @@ -1,88 +1,205 @@ import json import logging import os +from typing import List +from flask import Flask +from flask_appbuilder import AppBuilder from flask_appbuilder import SQLA -from flask_appbuilder.security.sqla.models import Permission, Role, ViewMenu +from flask_appbuilder.security.sqla.models import Permission, Role, User, ViewMenu +from flask_appbuilder.tests.base import FABTestCase +from flask_appbuilder.tests.const import PASSWORD_ADMIN, USERNAME_ADMIN +import prison +from werkzeug.security import generate_password_hash -from .base import FABTestCase -from .const import PASSWORD_ADMIN, USERNAME_ADMIN log = logging.getLogger(__name__) class UserAPITestCase(FABTestCase): - def setUp(self): - from flask import Flask - from flask_appbuilder import AppBuilder - from flask_appbuilder.security.sqla.models import User, Role - + def setUp(self) -> None: self.app = Flask(__name__) self.basedir = os.path.abspath(os.path.dirname(__file__)) - self.app.config.from_object("flask_appbuilder.tests.config_api") - self.app.config["FAB_ADD_SECURITY_API"] = True + self.app.config.from_object("flask_appbuilder.tests.config_security_api") self.db = SQLA(self.app) + self.session = self.db.session self.appbuilder = AppBuilder(self.app, self.session) self.user_model = User self.role_model = Role - # TODO: this heinous hack is to avoid using stale db session leaking from - # RolePermissionAPITestCase - # don't know why all baseviews in Appbuilder are attached to stale session, - # causing error when adding a new user which reads roles from this session and - # datamodel uses stale session to add it. - for b in self.appbuilder.baseviews: - if hasattr(b, "datamodel") and b.datamodel.session is not None: - b.datamodel.session = self.db.session - def tearDown(self): - self.appbuilder.get_session.close() - engine = self.db.session.get_bind(mapper=None, clause=None) + self.appbuilder.session.close() + engine = self.appbuilder.session.get_bind(mapper=None, clause=None) + for baseview in self.appbuilder.baseviews: + if hasattr(baseview, "datamodel"): + baseview.datamodel.session = None engine.dispose() + def _create_test_user( + self, + username: str, + password: str, + roles: List[Role], + email: str, + first_name="first-name", + last_name="last-name", + ): + user = User() + user.first_name = first_name + user.last_name = last_name + user.username = username + user.email = email + user.roles = roles + user.password = generate_password_hash(password) + self.session.commit() + return user + + def test_user_info(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/security/users/_info" + rv = self.auth_client_get(client, token, uri) + self.assertEqual(rv.status_code, 200) + def test_user_list(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - total_users = self.appbuilder.sm.count_users() - uri = "api/v1/users/" + query = {"order_column": "username", "order_direction": "desc"} + uri = f"api/v1/security/users/?q={prison.dumps(query)}" rv = self.auth_client_get(client, token, uri) response = json.loads(rv.data) self.assertEqual(rv.status_code, 200) assert "count" in response - self.assertEqual(response["count"], total_users) - self.assertEqual(len(response["result"]), total_users) + self.assertEqual(response["count"], 2) + self.assertEqual(len(response["result"]), 2) + expected_results = [ + { + "active": True, + "changed_by": None, + "created_by": None, + "created_on": "2020-01-01T00:00:00", + "email": "admin@fab.org", + "first_name": "admin", + "last_name": "user", + "roles": [{"id": 2, "name": "Admin"}], + "username": "testadmin", + }, + { + "active": True, + "changed_by": None, + "created_by": None, + "created_on": "2020-01-01T00:00:00", + "email": "readonly@fab.org", + "first_name": "readonly", + "last_name": "readonly", + "roles": [{"id": 1, "name": "ReadOnly"}], + "username": "readonly", + }, + ] + self.assert_response(response["result"], expected_results) + self.assertEqual( + [ + "active", + "changed_by", + "changed_on", + "created_by", + "created_on", + "email", + "fail_login_count", + "first_name", + "id", + "last_login", + "last_name", + "login_count", + "roles", + "username", + ], + list(response["result"][0].keys()), + ) - def test_get_single_user(self): + def test_user_list_search_username(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - username = "test_get_single_user_1" - first_name = "first" - last_name = "last" - email = "test_get_single_user@fab.com" - password = "a" - role_name = "get_single_user_role" + query = {"filters": [{"col": "username", "opr": "eq", "value": "readonly"}]} + uri = f"api/v1/security/users/?q={prison.dumps(query)}" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) - role = self.appbuilder.sm.add_role(role_name) - user = self.appbuilder.sm.add_user( - username, first_name, last_name, email, role, password - ) + expected_results = [ + { + "active": True, + "changed_by": None, + "changed_on": "2020-01-01T00:00:00", + "created_by": None, + "created_on": "2020-01-01T00:00:00", + "email": "readonly@fab.org", + "first_name": "readonly", + "last_name": "readonly", + "roles": [{"id": 1, "name": "ReadOnly"}], + "username": "readonly", + } + ] + self.assert_response(response["result"], expected_results) + self.assertEqual(rv.status_code, 200) + assert "count" in response + self.assertEqual(response["count"], 1) + + def test_user_list_search_roles(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + query = {"filters": [{"col": "roles", "opr": "rel_m_m", "value": 2}]} + uri = f"api/v1/security/users/?q={prison.dumps(query)}" + rv = self.auth_client_get(client, token, uri) + response = json.loads(rv.data) + + expected_results = [ + { + "active": True, + "changed_by": None, + "changed_on": "2020-01-01T00:00:00", + "created_by": None, + "created_on": "2020-01-01T00:00:00", + "email": "admin@fab.org", + "first_name": "admin", + "last_name": "user", + "roles": [{"id": 2, "name": "Admin"}], + "username": "testadmin", + } + ] + self.assert_response(response["result"], expected_results) + self.assertEqual(rv.status_code, 200) + assert "count" in response + self.assertEqual(response["count"], 1) + + def test_get_single_user(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = f"api/v1/users/{user.id}" + role = Role(name="test-role") + self.session.add(role) + self.session.commit() + role_id = role.id + user = self._create_test_user( + "test-get-single-user", "password", [role], "test-get-single-user@fab.com" + ) + uri = f"api/v1/security/users/{user.id}" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 200) response = json.loads(rv.data) assert "result" in response result = response["result"] - self.assertEqual(result["username"], username) - self.assertEqual(result["first_name"], first_name) - self.assertEqual(result["last_name"], last_name) - self.assertEqual(result["email"], email) - self.assertEqual(result["roles"], [{"id": role.id, "name": role_name}]) + self.assertEqual(result["username"], "test-get-single-user") + self.assertEqual(result["first_name"], "first-name") + self.assertEqual(result["last_name"], "last-name") + self.assertEqual(result["email"], "test-get-single-user@fab.com") + self.assertEqual(result["roles"], [{"id": role_id, "name": "test-role"}]) user = ( self.session.query(self.user_model) @@ -92,18 +209,18 @@ def test_get_single_user(self): self.session.delete(user) role = ( self.session.query(self.role_model) - .filter(self.role_model.id == role.id) + .filter(self.role_model.id == role_id) .first() ) self.session.delete(role) self.session.commit() - def test_get_single_invalid_user(self): + def test_get_single_not_found(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/users/99999999" + uri = "api/v1/security/users/99999999" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) response = json.loads(rv.data) @@ -114,11 +231,11 @@ def test_get_single_invalid_user(self): def test_create_user(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + role = Role(name="test-create-user-api") + self.session.add(role) + self.session.commit() - role_name = "test_create_user_api" - role = self.appbuilder.sm.add_role(role_name) - - uri = "api/v1/users/" + uri = "api/v1/security/users/" create_user_payload = { "active": True, "email": "fab@test_create_user_3.com", @@ -133,16 +250,18 @@ def test_create_user(self): self.assertEqual(rv.status_code, 201) assert "id" in add_user_response - - user = self.appbuilder.sm.get_user_by_id(add_user_response["id"]) - + user = ( + self.session.query(User) + .filter(User.id == add_user_response["id"]) + .one_or_none() + ) self.assertEqual(user.active, create_user_payload["active"]) self.assertEqual(user.email, create_user_payload["email"]) self.assertEqual(user.first_name, create_user_payload["first_name"]) self.assertEqual(user.last_name, create_user_payload["last_name"]) self.assertEqual(user.username, create_user_payload["username"]) self.assertEqual(len(user.roles), 1) - self.assertEqual(user.roles[0].name, role_name) + self.assertEqual(user.roles[0].name, "test-create-user-api") user = ( self.session.query(self.user_model) @@ -156,7 +275,7 @@ def test_create_user_without_role(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/users/" + uri = "api/v1/security/users/" create_user_payload = { "active": True, "email": "fab@test_create_user_1.com", @@ -179,7 +298,7 @@ def test_create_user_with_invalid_role(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/users/" + uri = "api/v1/security/users/" create_user_payload = { "active": True, "email": "fab@test_create_user_1.com", @@ -214,40 +333,35 @@ def test_edit_user(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - username = "edit_user_13" - first_name = "first" - last_name = "last" - email = "test_edit_user13@fab.com" - password = "a" - role_name_1 = "edit_user_role_1" - role_name_2 = "edit_user_role_2" - role_name_3 = "edit_user_role_3" updated_email = "test_edit_user_new7@fab.com" - role_1 = self.appbuilder.sm.add_role(role_name_1) - role_2 = self.appbuilder.sm.add_role(role_name_2) - role_3 = self.appbuilder.sm.add_role(role_name_3) - user = self.appbuilder.sm.add_user( - username, first_name, last_name, email, [role_1], password + role_1 = Role(name="test-role1") + role_2 = Role(name="test-role2") + role_3 = Role(name="test-role3") + self.session.add(role_1) + self.session.add(role_2) + self.session.add(role_3) + self.session.commit() + user = self._create_test_user( + "edit-user-1", "password", [role_1], "test-edit-user1@fab.com" ) - user_id = user.id role_1_id = role_1.id role_2_id = role_2.id role_3_id = role_3.id - uri = f"api/v1/users/{user_id}" + uri = f"api/v1/security/users/{user.id}" rv = self.auth_client_put( client, token, uri, - {"email": updated_email, "roles": [role_2_id, role_3_id]}, + {"email": updated_email, "roles": [role_2.id, role_3.id]}, ) self.assertEqual(rv.status_code, 200) - updated_user = self.appbuilder.sm.get_user_by_id(user_id) + updated_user = self.session.query(self.user_model).get(user_id) self.assertEqual(len(updated_user.roles), 2) - self.assertEqual(updated_user.roles[0].name, role_name_2) - self.assertEqual(updated_user.roles[1].name, role_name_3) + self.assertEqual(updated_user.roles[0].name, "test-role2") + self.assertEqual(updated_user.roles[1].name, "test-role3") self.assertEqual(updated_user.email, updated_email) roles = ( @@ -268,22 +382,19 @@ def test_edit_user(self): def test_delete_user(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + session = self.appbuilder.session - username = "delete_user_2" - first_name = "first" - last_name = "last" - email = "test_delete_user_2@fab.com" - password = "a" - role_name_1 = "delete_user_role_2" + role = Role(name="delete-user-role") - role = self.appbuilder.sm.add_role(role_name_1) - user = self.appbuilder.sm.add_user( - username, first_name, last_name, email, [role], password + session.add(role) + session.commit() + user = self._create_test_user( + "delete-user", "password", [role], "delete-user@fab.com" ) role_id = role.id user_id = user.id - uri = f"api/v1/users/{user_id}" + uri = f"api/v1/security/users/{user_id}" rv = self.auth_client_delete(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -291,12 +402,20 @@ def test_delete_user(self): assert not updated_user role = ( - self.session.query(self.role_model) + session.query(self.role_model) .filter(self.role_model.id == role_id) - .first() + .one_or_none() ) - self.session.delete(role) - self.session.commit() + session.delete(role) + session.commit() + + def test_delete_user_not_found(self): + client = self.app.test_client() + token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) + + uri = "api/v1/security/users/999999" + rv = self.auth_client_delete(client, token, uri) + self.assertEqual(rv.status_code, 404) class RolePermissionAPITestCase(FABTestCase): @@ -320,8 +439,11 @@ def setUp(self): b.datamodel.session = self.db.session def tearDown(self): - self.appbuilder.get_session.close() - engine = self.db.session.get_bind(mapper=None, clause=None) + self.appbuilder.session.close() + engine = self.appbuilder.session.get_bind(mapper=None, clause=None) + for baseview in self.appbuilder.baseviews: + if hasattr(baseview, "datamodel"): + baseview.datamodel.session = None engine.dispose() def test_list_permission_api(self): @@ -330,7 +452,7 @@ def test_list_permission_api(self): count = self.session.query(self.permission_model).count() - uri = "api/v1/permissions/" + uri = "api/v1/security/permissions/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -347,7 +469,7 @@ def test_get_permission_api(self): permission = self.appbuilder.sm.add_permission(permission_name) permission_id = permission.id - uri = f"api/v1/permissions/{permission_id}" + uri = f"api/v1/security/permissions/{permission_id}" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -363,7 +485,7 @@ def test_get_invalid_permission_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/permissions/9999999" + uri = "api/v1/security/permissions/9999999" rv = self.auth_client_get(client, token, uri) response = json.loads(rv.data) @@ -374,7 +496,7 @@ def test_add_permission_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/permissions/" + uri = "api/v1/security/permissions/" permission_name = "super duper fab permission" create_permission_payload = {"name": permission_name} @@ -392,7 +514,7 @@ def test_edit_permission_api(self): permission = self.appbuilder.sm.add_permission(permission_name) permission_id = permission.id - uri = f"api/v1/permissions/{permission_id}" + uri = f"api/v1/security/permissions/{permission_id}" rv = self.auth_client_put(client, token, uri, {"name": new_permission_name}) self.assertEqual(rv.status_code, 405) @@ -409,7 +531,7 @@ def test_delete_permission_api(self): permission_name = "test_delete_permission_api" permission = self.appbuilder.sm.add_permission(permission_name) - uri = f"api/v1/permissions/{permission.id}" + uri = f"api/v1/security/permissions/{permission.id}" rv = self.auth_client_delete(client, token, uri) self.assertEqual(rv.status_code, 405) @@ -425,7 +547,7 @@ def test_list_view_api(self): count = self.session.query(self.viewmenu_model).count() - uri = "api/v1/viewmenus/" + uri = "api/v1/security/resources/" rv = self.auth_client_get(client, token, uri) response = json.loads(rv.data) @@ -441,7 +563,7 @@ def test_get_view_api(self): view = self.appbuilder.sm.add_view_menu(view_name) view_id = view.id - uri = f"api/v1/viewmenus/{view_id}" + uri = f"api/v1/security/resources/{view_id}" rv = self.auth_client_get(client, token, uri) response = json.loads(rv.data) @@ -456,7 +578,7 @@ def test_get_invalid_view_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/viewmenus/99999999" + uri = "api/v1/security/resources/99999999" rv = self.auth_client_get(client, token, uri) response = json.loads(rv.data) @@ -468,7 +590,7 @@ def test_add_view_api(self): token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) view_name = "super duper fab view" - uri = "api/v1/viewmenus/" + uri = "api/v1/security/resources/" create_permission_payload = {"name": view_name} rv = self.auth_client_post(client, token, uri, create_permission_payload) add_permission_response = json.loads(rv.data) @@ -483,7 +605,7 @@ def test_add_view_without_name_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/viewmenus/" + uri = "api/v1/security/resources/" create_view_payload = {} rv = self.auth_client_post(client, token, uri, create_view_payload) add_permission_response = json.loads(rv.data) @@ -503,7 +625,7 @@ def test_edit_view_api(self): view_menu = self.appbuilder.sm.add_view_menu(view_name) view_menu_id = view_menu.id - uri = f"api/v1/viewmenus/{view_menu_id}" + uri = f"api/v1/security/resources/{view_menu_id}" rv = self.auth_client_put(client, token, uri, {"name": new_view_name}) put_permission_response = json.loads(rv.data) self.assertEqual(rv.status_code, 200) @@ -524,7 +646,7 @@ def test_delete_view_api(self): view_menu_name = "test_delete_view_api" view_menu = self.appbuilder.sm.add_view_menu(view_menu_name) - uri = f"api/v1/viewmenus/{view_menu.id}" + uri = f"api/v1/security/resources/{view_menu.id}" rv = self.auth_client_delete(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -537,7 +659,7 @@ def test_list_permission_view_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/permissionsviewmenus/" + uri = "api/v1/security/permissions-resources/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -551,7 +673,7 @@ def test_get_permission_view_api(self): permission_name, view_name ) - uri = f"api/v1/permissionsviewmenus/{permission_view_menu.id}" + uri = f"api/v1/security/permissions-resources/{permission_view_menu.id}" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -561,7 +683,7 @@ def test_get_invalid_permission_view_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/permissionsviewmenus/9999999" + uri = "api/v1/security/permissions-resources/9999999" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) @@ -575,7 +697,7 @@ def test_add_permission_view_api(self): permission = self.appbuilder.sm.add_permission(permission_name) view_menu = self.appbuilder.sm.add_view_menu(view_menu_name) - uri = "api/v1/permissionsviewmenus/" + uri = "api/v1/security/permissions-resources/" create_permission_payload = { "permission_id": permission.id, "view_menu_id": view_menu.id, @@ -605,7 +727,7 @@ def test_edit_permission_view_api(self): new_view_menu_id = new_view_menu.id - uri = f"api/v1/permissionsviewmenus/{permission_view_menu.id}" + uri = f"api/v1/security/permissions-resources/{permission_view_menu.id}" rv = self.auth_client_put( client, token, uri, {"view_menu_id": new_view_menu.id} ) @@ -631,7 +753,7 @@ def test_delete_permission_view_api(self): permission_name, view_name ) - uri = f"api/v1/permissionsviewmenus/{permission_view_menu.id}" + uri = f"api/v1/security/permissions-resources/{permission_view_menu.id}" rv = self.auth_client_delete(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -644,7 +766,7 @@ def test_list_role_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/roles/" + uri = "api/v1/security/roles/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -656,7 +778,7 @@ def test_get_role_api(self): role = self.appbuilder.sm.add_role(role_name) role_id = role.id - uri = f"api/v1/roles/{role_id}" + uri = f"api/v1/security/roles/{role_id}" rv = self.auth_client_get(client, token, uri) response = json.loads(rv.data) @@ -671,7 +793,7 @@ def test_create_role_api(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/roles/" + uri = "api/v1/security/roles/" role_name = "test_create_role_api" create_user_payload = {"name": role_name} rv = self.auth_client_post(client, token, uri, create_user_payload) @@ -699,7 +821,7 @@ def test_edit_role_api(self): role_id = role.id - uri = f"api/v1/roles/{role_id}" + uri = f"api/v1/security/roles/{role_id}" rv = self.auth_client_put(client, token, uri, {"name": role_2_name}) put_role_response = json.loads(rv.data) @@ -743,7 +865,7 @@ def test_add_view_menu_permissions_to_role(self): permission_1_view_menu_id = permission_1_view_menu.id permission_2_view_menu_id = permission_2_view_menu.id - uri = f"api/v1/roles/{role_id}/permissions" + uri = f"api/v1/security/roles/{role_id}/permissions" rv = self.auth_client_post( client, token, @@ -793,7 +915,7 @@ def test_add_invalid_view_menu_permissions_to_role(self): role = self.appbuilder.sm.add_role(role_name) role_id = role.id - uri = f"api/v1/roles/{role_id}/permissions" + uri = f"api/v1/security/roles/{role_id}/permissions" rv = self.auth_client_post(client, token, uri, {}) self.assertEqual(rv.status_code, 400) @@ -816,7 +938,7 @@ def test_add_view_menu_permissions_to_invalid_role(self): permission_2_name, view_menu_name ) - uri = f"api/v1/roles/{9999999}/permissions" + uri = f"api/v1/security/roles/{9999999}/permissions" rv = self.auth_client_post( client, token, @@ -860,7 +982,7 @@ def test_list_view_menu_permissions_of_role(self): permission_1_view_menu_id = permission_1_view_menu.id permission_2_view_menu_id = permission_2_view_menu.id - uri = f"api/v1/roles/{role_id}/permissions" + uri = f"api/v1/security/roles/{role_id}/permissions/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -892,7 +1014,7 @@ def test_list_view_menu_permissions_of_invalid_role(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = f"api/v1/roles/{999999}/permissions" + uri = f"api/v1/security/roles/{999999}/permissions/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) @@ -910,7 +1032,7 @@ def test_delete_role_api(self): ) role = self.appbuilder.sm.add_role(role_name, [permission_1_view_menu]) - uri = f"api/v1/roles/{role.id}" + uri = f"api/v1/security/roles/{role.id}" rv = self.auth_client_delete(client, token, uri) self.assertEqual(rv.status_code, 200) @@ -930,31 +1052,34 @@ def setUp(self): self.appbuilder = AppBuilder(self.app, self.db.session) def tearDown(self): - self.appbuilder.get_session.close() - engine = self.db.session.get_bind(mapper=None, clause=None) + self.appbuilder.session.close() + engine = self.appbuilder.session.get_bind(mapper=None, clause=None) + for baseview in self.appbuilder.baseviews: + if hasattr(baseview, "datamodel"): + baseview.datamodel.session = None engine.dispose() def test_user_role_permission(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/users/" + uri = "api/v1/security/users/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) - uri = "api/v1/roles/" + uri = "api/v1/security/roles/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) - uri = "api/v1/permissions/" + uri = "api/v1/security/permissions/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) - uri = "api/v1/viewmenus/" + uri = "api/v1/security/viewmenus/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) - uri = "api/v1/permissionsviewmenus/" + uri = "api/v1/security/permissionsviewmenus/" rv = self.auth_client_get(client, token, uri) self.assertEqual(rv.status_code, 404) @@ -980,21 +1105,19 @@ def passwordValidator(password): self.appbuilder = AppBuilder(self.app, self.db.session) self.user_model = User - # TODO:remove this hack - for b in self.appbuilder.baseviews: - if hasattr(b, "datamodel") and b.datamodel.session is not None: - b.datamodel.session = self.db.session - def tearDown(self): - self.appbuilder.get_session.close() - engine = self.db.session.get_bind(mapper=None, clause=None) + self.appbuilder.session.close() + engine = self.appbuilder.session.get_bind(mapper=None, clause=None) + for baseview in self.appbuilder.baseviews: + if hasattr(baseview, "datamodel"): + baseview.datamodel.session = None engine.dispose() def test_password_complexity(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/users/" + uri = "api/v1/security/users/" create_user_payload = { "active": True, "email": "fab@usertest1.com", @@ -1041,21 +1164,19 @@ def passwordValidator(password): self.appbuilder = AppBuilder(self.app, self.db.session) self.user_model = User - # TODO:remove this hack - for b in self.appbuilder.baseviews: - if hasattr(b, "datamodel") and b.datamodel.session is not None: - b.datamodel.session = self.db.session - def tearDown(self): - self.appbuilder.get_session.close() - engine = self.db.session.get_bind(mapper=None, clause=None) + self.appbuilder.session.close() + engine = self.appbuilder.session.get_bind(mapper=None, clause=None) + for baseview in self.appbuilder.baseviews: + if hasattr(baseview, "datamodel"): + baseview.datamodel.session = None engine.dispose() def test_password_complexity(self): client = self.app.test_client() token = self.login(client, USERNAME_ADMIN, PASSWORD_ADMIN) - uri = "api/v1/users/" + uri = "api/v1/security/users/" create_user_payload = { "active": True, "email": "fab@defalultpasswordtest.com", diff --git a/requirements-dev.txt b/requirements-dev.txt index 1beb0c693b..f146c99bdc 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,3 +10,4 @@ nose==1.3.7 parameterized==0.8.1 pip-tools==6.2.0 tox==3.24.3 +freezegun==1.2.1 diff --git a/setup.cfg b/setup.cfg index fcf4c00074..17777383cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,3 +22,10 @@ check_untyped_defs = true disallow_untyped_calls = false disallow_untyped_defs = true warn_unused_ignores = false + +[mypy-flask_appbuilder.base] +ignore_errors = False +check_untyped_defs = true +disallow_untyped_calls = false +disallow_untyped_defs = true +warn_unused_ignores = false From cc4c0b3e146d815d35b02bf3f532e55deac2411f Mon Sep 17 00:00:00 2001 From: Mathew Wicks Date: Fri, 29 Apr 2022 23:09:29 +1000 Subject: [PATCH 014/113] docs: add Azure OAUTH example (#1837) Co-authored-by: Daniel Vaz Gaspar --- docs/security.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/security.rst b/docs/security.rst index acac362918..602fc36237 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -233,6 +233,23 @@ Specify a list of OAUTH_PROVIDERS in **config.py** that you want to allow for yo "authorize_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/authorize", }, }, + { + "name": "azure", + "icon": "fa-windows", + "token_key": "access_token", + "remote_app": { + "client_id": "AZURE_APPLICATION_ID", + "client_secret": "AZURE_SECRET", + "api_base_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2", + "client_kwargs": { + "scope": "User.read name preferred_username email profile upn", + "resource": "AZURE_APPLICATION_ID", + }, + "request_token_url": None, + "access_token_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/token", + "authorize_url": "https://login.microsoftonline.com/AZURE_TENANT_ID/oauth2/authorize", + }, + }, ] This needs a small explanation, you basically have five special keys: From b21010249a33f657897d26b0a7bec93908290266 Mon Sep 17 00:00:00 2001 From: nilivingston <96078275+nilivingston@users.noreply.github.com> Date: Mon, 2 May 2022 02:22:05 -0600 Subject: [PATCH 015/113] feat: add keycloak auth provider options (#1832) * Add Keycloak provider * Add option for Keycloak before 17 * Black security/manager.py * Add missing curly braces Co-authored-by: Nick Livingston Co-authored-by: Daniel Vaz Gaspar --- docs/security.rst | 34 +++- examples/oauth/config.py | 44 ++++- flask_appbuilder/security/manager.py | 272 ++++++++++++++------------- 3 files changed, 219 insertions(+), 131 deletions(-) diff --git a/docs/security.rst b/docs/security.rst index 602fc36237..b927a02be5 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -233,6 +233,38 @@ Specify a list of OAUTH_PROVIDERS in **config.py** that you want to allow for yo "authorize_url": "https://COGNITO_APP.auth.REGION.amazoncognito.com/authorize", }, }, + { + "name": "keycloak", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": "KEYCLOAK_CLIENT_ID", + "client_secret": "KEYCLOAK_CLIENT_SECRET", + "api_base_url": "https://KEYCLOAK_DOMAIN/realms/master/protocol/openid-connect", + "client_kwargs": { + "scope": "email profile" + }, + "access_token_url": "KEYCLOAK_DOMAIN/realms/master/protocol/openid-connect/token", + "authorize_url": "KEYCLOAK_DOMAIN/realms/master/protocol/openid-connect/auth", + "request_token_url": None, + }, + }, + { + "name": "keycloak_before_17", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": "KEYCLOAK_CLIENT_ID", + "client_secret": "KEYCLOAK_CLIENT_SECRET", + "api_base_url": "https://KEYCLOAK_DOMAIN/auth/realms/master/protocol/openid-connect", + "client_kwargs": { + "scope": "email profile" + }, + "access_token_url": "KEYCLOAK_DOMAIN/auth/realms/master/protocol/openid-connect/token", + "authorize_url": "KEYCLOAK_DOMAIN/auth/realms/master/protocol/openid-connect/auth", + "request_token_url": None, + }, + }, { "name": "azure", "icon": "fa-windows", @@ -256,7 +288,7 @@ This needs a small explanation, you basically have five special keys: :name: the name of the provider: you can choose whatever you want, but FAB has builtin logic in `BaseSecurityManager.get_oauth_user_info()` for: - 'azure', 'github', 'google', 'linkedin', 'okta', 'openshift', 'twitter' + 'azure', 'github', 'google', 'keycloak', 'keycloak_before_17', 'linkedin', 'okta', 'openshift', 'twitter' :icon: the font-awesome icon for this provider diff --git a/examples/oauth/config.py b/examples/oauth/config.py index 20d093c6ab..115424f6f9 100644 --- a/examples/oauth/config.py +++ b/examples/oauth/config.py @@ -51,7 +51,9 @@ "request_token_url": "https://api.twitter.com/oauth/request_token", "access_token_url": "https://api.twitter.com/oauth/access_token", "authorize_url": "https://api.twitter.com/oauth/authenticate", - "fetch_token": lambda: session.get("oauth_token"), # DON'T DO THIS IN PRODUCTION + "fetch_token": lambda: session.get( + "oauth_token" + ), # DON'T DO THIS IN PRODUCTION }, }, { @@ -104,6 +106,46 @@ ), }, }, + { + "name": "keycloak", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), + "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), + "api_base_url": "https://{}}/realms/master/protocol/openid-connect".format( + os.environ.get("KEYCLOAK_DOMAIN") + ), + "client_kwargs": {"scope": "email profile"}, + "access_token_url": "https://{}/realms/master/protocol/openid-connect/token".format( + os.environ.get("KEYCLOAK_DOMAIN") + ), + "authorize_url": "https://{}/realms/master/protocol/openid-connect/auth".format( + os.environ.get("KEYCLOAK_DOMAIN") + ), + "request_token_url": None, + }, + }, + { + "name": "keycloak_before_17", + "icon": "fa-key", + "token_key": "access_token", + "remote_app": { + "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), + "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), + "api_base_url": "https://{}}/auth/realms/master/protocol/openid-connect".format( + os.environ.get("KEYCLOAK_DOMAIN") + ), + "client_kwargs": {"scope": "email profile"}, + "access_token_url": "https://{}/auth/realms/master/protocol/openid-connect/token".format( + os.environ.get("KEYCLOAK_DOMAIN") + ), + "authorize_url": "https://{}/auth/realms/master/protocol/openid-connect/auth".format( + os.environ.get("KEYCLOAK_DOMAIN") + ), + "request_token_url": None, + }, + }, ] # Uncomment to setup Full admin role name diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 34245a5784..4fe3962d46 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -60,51 +60,51 @@ class AbstractSecurityManager(BaseManager): """ - Abstract SecurityManager class, declares all methods used by the - framework. There is no assumptions about security models or auth types. + Abstract SecurityManager class, declares all methods used by the + framework. There is no assumptions about security models or auth types. """ def add_permissions_view(self, base_permissions, view_menu): """ - Adds a permission on a view menu to the backend + Adds a permission on a view menu to the backend - :param base_permissions: - list of permissions from view (all exposed methods): - 'can_add','can_edit' etc... - :param view_menu: - name of the view or menu to add + :param base_permissions: + list of permissions from view (all exposed methods): + 'can_add','can_edit' etc... + :param view_menu: + name of the view or menu to add """ raise NotImplementedError def add_permissions_menu(self, view_menu_name): """ - Adds menu_access to menu on permission_view_menu + Adds menu_access to menu on permission_view_menu - :param view_menu_name: - The menu name + :param view_menu_name: + The menu name """ raise NotImplementedError def register_views(self): """ - Generic function to create the security views + Generic function to create the security views """ raise NotImplementedError def is_item_public(self, permission_name, view_name): """ - Check if view has public permissions + Check if view has public permissions - :param permission_name: - the permission: can_show, can_edit... - :param view_name: - the name of the class view (child of BaseView) + :param permission_name: + the permission: can_show, can_edit... + :param view_name: + the name of the class view (child of BaseView) """ raise NotImplementedError def has_access(self, permission_name, view_name): """ - Check if current user or public has access to view or menu + Check if current user or public has access to view or menu """ raise NotImplementedError @@ -120,8 +120,8 @@ def noop_user_update(self, user) -> None: def _oauth_tokengetter(token=None): """ - Default function to return the current user oauth token - from session cookie. + Default function to return the current user oauth token + from session cookie. """ token = session.get("oauth") log.debug("Token Get: {0}".format(token)) @@ -287,9 +287,9 @@ def __init__(self, appbuilder): def create_login_manager(self, app) -> LoginManager: """ - Override to implement your custom login manager instance + Override to implement your custom login manager instance - :param app: Flask app + :param app: Flask app """ lm = LoginManager(app) lm.login_view = "login" @@ -298,9 +298,9 @@ def create_login_manager(self, app) -> LoginManager: def create_jwt_manager(self, app) -> JWTManager: """ - Override to implement your custom JWT manager instance + Override to implement your custom JWT manager instance - :param app: Flask app + :param app: Flask app """ jwt_manager = JWTManager() jwt_manager.init_app(app) @@ -498,21 +498,21 @@ def current_user(self): def oauth_user_info_getter(self, f): """ - Decorator function to be the OAuth user info getter - for all the providers, receives provider and response - return a dict with the information returned from the provider. - The returned user info dict should have it's keys with the same - name as the User Model. + Decorator function to be the OAuth user info getter + for all the providers, receives provider and response + return a dict with the information returned from the provider. + The returned user info dict should have it's keys with the same + name as the User Model. - Use it like this an example for GitHub :: + Use it like this an example for GitHub :: - @appbuilder.sm.oauth_user_info_getter - def my_oauth_user_info(sm, provider, response=None): - if provider == 'github': - me = sm.oauth_remotes[provider].get('user') - return {'username': me.data.get('login')} - else: - return {} + @appbuilder.sm.oauth_user_info_getter + def my_oauth_user_info(sm, provider, response=None): + if provider == 'github': + me = sm.oauth_remotes[provider].get('user') + return {'username': me.data.get('login')} + else: + return {} """ def wraps(provider, response=None): @@ -531,9 +531,9 @@ def wraps(provider, response=None): def get_oauth_token_key_name(self, provider): """ - Returns the token_key name for the oauth provider - if none is configured defaults to oauth_token - this is configured using OAUTH_PROVIDERS and token_key key. + Returns the token_key name for the oauth provider + if none is configured defaults to oauth_token + this is configured using OAUTH_PROVIDERS and token_key key. """ for _provider in self.oauth_providers: if _provider["name"] == provider: @@ -541,9 +541,9 @@ def get_oauth_token_key_name(self, provider): def get_oauth_token_secret_name(self, provider): """ - Returns the token_secret name for the oauth provider - if none is configured defaults to oauth_secret - this is configured using OAUTH_PROVIDERS and token_secret + Returns the token_secret name for the oauth provider + if none is configured defaults to oauth_secret + this is configured using OAUTH_PROVIDERS and token_secret """ for _provider in self.oauth_providers: if _provider["name"] == provider: @@ -551,7 +551,7 @@ def get_oauth_token_secret_name(self, provider): def set_oauth_session(self, provider, oauth_response): """ - Set the current session with OAuth user secrets + Set the current session with OAuth user secrets """ # Get this provider key names for token_key and token_secret token_key = self.appbuilder.sm.get_oauth_token_key_name(provider) @@ -565,8 +565,8 @@ def set_oauth_session(self, provider, oauth_response): def get_oauth_user_info(self, provider, resp): """ - Since there are different OAuth API's with different ways to - retrieve user info + Since there are different OAuth API's with different ways to + retrieve user info """ # for GITHUB if provider == "github" or provider == "githublocal": @@ -644,6 +644,20 @@ def get_oauth_user_info(self, provider, resp): "email": data.get("email", ""), "role_keys": data.get("groups", []), } + # for Keycloak + if provider in ["keycloak", "keycloak_before_17"]: + me = self.appbuilder.sm.oauth_remotes[provider].get( + "openid-connect/userinfo" + ) + me.raise_for_status() + data = me.json() + log.debug("User info from Keycloak: %s", data) + return { + "username": data.get("preferred_username", ""), + "first_name": data.get("given_name", ""), + "last_name": data.get("family_name", ""), + "email": data.get("email", ""), + } else: return {} @@ -787,7 +801,7 @@ def register_views(self): def create_db(self): """ - Setups the DB, creates admin and public roles if they don't exist. + Setups the DB, creates admin and public roles if they don't exist. """ roles_mapping = self.appbuilder.get_app.config.get("FAB_ROLES_MAPPING", {}) for pk, name in roles_mapping.items(): @@ -806,13 +820,13 @@ def create_db(self): def reset_password(self, userid, password): """ - Change/Reset a user's password for authdb. - Password will be hashed and saved. + Change/Reset a user's password for authdb. + Password will be hashed and saved. - :param userid: - the user.id to reset the password - :param password: - The clear text password to reset and save hashed on the db + :param userid: + the user.id to reset the password + :param password: + The clear text password to reset and save hashed on the db """ user = self.get_user_by_id(userid) user.password = generate_password_hash(password) @@ -884,12 +898,12 @@ def auth_user_db(self, username, password): def _search_ldap(self, ldap, con, username): """ - Searches LDAP for user. + Searches LDAP for user. - :param ldap: The ldap module reference - :param con: The ldap connection - :param username: username to match with AUTH_LDAP_UID_FIELD - :return: ldap object array + :param ldap: The ldap module reference + :param con: The ldap connection + :param username: username to match with AUTH_LDAP_UID_FIELD + :return: ldap object array """ # always check AUTH_LDAP_SEARCH is set before calling this method assert self.auth_ldap_search, "AUTH_LDAP_SEARCH must be set" @@ -979,10 +993,10 @@ def _ldap_calculate_user_roles( def _ldap_bind_indirect(self, ldap, con) -> None: """ - Attempt to bind to LDAP using the AUTH_LDAP_BIND_USER. + Attempt to bind to LDAP using the AUTH_LDAP_BIND_USER. - :param ldap: The ldap module reference - :param con: The ldap connection + :param ldap: The ldap module reference + :param con: The ldap connection """ # always check AUTH_LDAP_BIND_USER is set before calling this method assert self.auth_ldap_bind_user, "AUTH_LDAP_BIND_USER must be set" @@ -1009,7 +1023,7 @@ def _ldap_bind_indirect(self, ldap, con) -> None: @staticmethod def _ldap_bind(ldap, con, dn: str, password: str) -> bool: """ - Validates/binds the provided dn/password with the LDAP sever. + Validates/binds the provided dn/password with the LDAP sever. """ try: log.debug("LDAP bind TRY with username: '{0}'".format(dn)) @@ -1035,12 +1049,12 @@ def ldap_extract_list(ldap_dict: Dict[str, bytes], field_name: str) -> List[str] def auth_user_ldap(self, username, password): """ - Method for authenticating user with LDAP. + Method for authenticating user with LDAP. - NOTE: this depends on python-ldap module + NOTE: this depends on python-ldap module - :param username: the username - :param password: the password + :param username: the username + :param password: the password """ # If no username is provided, go away if (username is None) or username == "": @@ -1232,10 +1246,10 @@ def auth_user_ldap(self, username, password): def auth_user_oid(self, email): """ - OpenID user Authentication + OpenID user Authentication - :param email: user's email to authenticate - :type self: User model + :param email: user's email to authenticate + :type self: User model """ user = self.find_user(email=email) if user is None or (not user.is_active): @@ -1247,10 +1261,10 @@ def auth_user_oid(self, email): def auth_user_remote_user(self, username): """ - REMOTE_USER user Authentication + REMOTE_USER user Authentication - :param username: user's username for remote auth - :type self: User model + :param username: user's username for remote auth + :type self: User model """ user = self.find_user(username=username) @@ -1311,10 +1325,10 @@ def _oauth_calculate_user_roles(self, userinfo) -> List[str]: def auth_user_oauth(self, userinfo): """ - Method for authenticating user with OAuth. + Method for authenticating user with OAuth. - :userinfo: dict with user information - (keys are the same as User model columns) + :userinfo: dict with user information + (keys are the same as User model columns) """ # extract the username from `userinfo` if "username" in userinfo: @@ -1382,12 +1396,12 @@ def auth_user_oauth(self, userinfo): def is_item_public(self, permission_name, view_name): """ - Check if view has public permissions + Check if view has public permissions - :param permission_name: - the permission: can_show, can_edit... - :param view_name: - the name of the class view (child of BaseView) + :param permission_name: + the permission: can_show, can_edit... + :param view_name: + the name of the class view (child of BaseView) """ permissions = self.get_public_permissions() if permissions: @@ -1404,7 +1418,7 @@ def _has_access_builtin_roles( self, role, permission_name: str, view_name: str ) -> bool: """ - Checks permission on builtin role + Checks permission on builtin role """ builtin_pvms = self.builtin_roles.get(role.name, []) for pvm in builtin_pvms: @@ -1798,7 +1812,7 @@ def security_converge( def find_register_user(self, registration_hash): """ - Generic function to return user registration + Generic function to return user registration """ raise NotImplementedError @@ -1806,31 +1820,31 @@ def add_register_user( self, username, first_name, last_name, email, password="", hashed_password="" ): """ - Generic function to add user registration + Generic function to add user registration """ raise NotImplementedError def del_register_user(self, register_user): """ - Generic function to delete user registration + Generic function to delete user registration """ raise NotImplementedError def get_user_by_id(self, pk): """ - Generic function to return user by it's id (pk) + Generic function to return user by it's id (pk) """ raise NotImplementedError def find_user(self, username=None, email=None): """ - Generic function find a user by it's username or email + Generic function find a user by it's username or email """ raise NotImplementedError def get_all_users(self): """ - Generic function that returns all existing users + Generic function that returns all existing users """ raise NotImplementedError @@ -1842,21 +1856,21 @@ def get_db_role_permissions(self, role_id: int) -> List[object]: def add_user(self, username, first_name, last_name, email, role, password=""): """ - Generic function to create user + Generic function to create user """ raise NotImplementedError def update_user(self, user): """ - Generic function to update user + Generic function to update user - :param user: User model to update to database + :param user: User model to update to database """ raise NotImplementedError def count_users(self): """ - Generic function to count the existing users + Generic function to count the existing users """ raise NotImplementedError @@ -1886,19 +1900,19 @@ def get_all_roles(self): def get_public_role(self): """ - returns all permissions from public role + returns all permissions from public role """ raise NotImplementedError def get_public_permissions(self): """ - returns all permissions from public role + returns all permissions from public role """ raise NotImplementedError def find_permission(self, name): """ - Finds and returns a Permission by name + Finds and returns a Permission by name """ raise NotImplementedError @@ -1911,25 +1925,25 @@ def exist_permission_on_roles( self, view_name: str, permission_name: str, role_ids: List[int] ) -> bool: """ - Finds and returns permission views for a group of roles + Finds and returns permission views for a group of roles """ raise NotImplementedError def add_permission(self, name): """ - Adds a permission to the backend, model permission + Adds a permission to the backend, model permission - :param name: - name of the permission: 'can_add','can_edit' etc... + :param name: + name of the permission: 'can_add','can_edit' etc... """ raise NotImplementedError def del_permission(self, name): """ - Deletes a permission from the backend, model permission + Deletes a permission from the backend, model permission - :param name: - name of the permission: 'can_add','can_edit' etc... + :param name: + name of the permission: 'can_add','can_edit' etc... """ raise NotImplementedError @@ -1941,7 +1955,7 @@ def del_permission(self, name): def find_view_menu(self, name): """ - Finds and returns a ViewMenu by name + Finds and returns a ViewMenu by name """ raise NotImplementedError @@ -1950,18 +1964,18 @@ def get_all_view_menu(self): def add_view_menu(self, name): """ - Adds a view or menu to the backend, model view_menu - param name: - name of the view menu to add + Adds a view or menu to the backend, model view_menu + param name: + name of the view menu to add """ raise NotImplementedError def del_view_menu(self, name): """ - Deletes a ViewMenu from the backend + Deletes a ViewMenu from the backend - :param name: - name of the ViewMenu + :param name: + name of the ViewMenu """ raise NotImplementedError @@ -1973,27 +1987,27 @@ def del_view_menu(self, name): def find_permission_view_menu(self, permission_name, view_menu_name): """ - Finds and returns a PermissionView by names + Finds and returns a PermissionView by names """ raise NotImplementedError def find_permissions_view_menu(self, view_menu): """ - Finds all permissions from ViewMenu, returns list of PermissionView + Finds all permissions from ViewMenu, returns list of PermissionView - :param view_menu: ViewMenu object - :return: list of PermissionView objects + :param view_menu: ViewMenu object + :return: list of PermissionView objects """ raise NotImplementedError def add_permission_view_menu(self, permission_name, view_menu_name): """ - Adds a permission on a view or menu to the backend + Adds a permission on a view or menu to the backend - :param permission_name: - name of the permission to add: 'can_add','can_edit' etc... - :param view_menu_name: - name of the view menu to add + :param permission_name: + name of the permission to add: 'can_add','can_edit' etc... + :param view_menu_name: + name of the view menu to add """ raise NotImplementedError @@ -2008,34 +2022,34 @@ def exist_permission_on_view(self, lst, permission, view_menu): def add_permission_role(self, role, perm_view): """ - Add permission-ViewMenu object to Role + Add permission-ViewMenu object to Role - :param role: - The role object - :param perm_view: - The PermissionViewMenu object + :param role: + The role object + :param perm_view: + The PermissionViewMenu object """ raise NotImplementedError def del_permission_role(self, role, perm_view): """ - Remove permission-ViewMenu object to Role + Remove permission-ViewMenu object to Role - :param role: - The role object - :param perm_view: - The PermissionViewMenu object + :param role: + The role object + :param perm_view: + The PermissionViewMenu object """ raise NotImplementedError def export_roles( self, path: Optional[str] = None, indent: Optional[Union[int, str]] = None ) -> None: - """ Exports roles to JSON file. """ + """Exports roles to JSON file.""" raise NotImplementedError def import_roles(self, path: str) -> None: - """ Imports roles from JSON file. """ + """Imports roles from JSON file.""" raise NotImplementedError def load_user(self, pk): From 2ff99c5dcdbf0f8a6b8d1ef8ac9378323671b3b2 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Mon, 2 May 2022 09:47:20 +0100 Subject: [PATCH 016/113] docs: add FAB_ADD_SECURITY_API config option (#1840) * docs: add FAB_ADD_SECURITY_API config option * add more explicit beta phase note --- docs/config.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/config.rst b/docs/config.rst index cd63be2d01..0640c84c56 100755 --- a/docs/config.rst +++ b/docs/config.rst @@ -257,6 +257,13 @@ Use config.py to configure the following parameters. By default it will use SQLL | FAB_SECURITY_MANAGER_CLASS | Declare a new custom SecurityManager | | | | class | No | +----------------------------------------+--------------------------------------------+-----------+ +| FAB_ADD_SECURITY_API | [Beta] Adds a CRUD REST API for users, | | +| | roles, permissions, view_menus. | No | +| | Further details on /swagger/v1 | | +| | All endpoints are under /api/v1/sercurity/ | | +| | [Note]: This feature is still in beta | | +| | breaking changes are likely to occur | | ++----------------------------------------+--------------------------------------------+-----------+ | FAB_ADD_SECURITY_VIEWS | Enables or disables registering all | | | | security views (boolean default:True) | No | +----------------------------------------+--------------------------------------------+-----------+ From b0fcd565abbc0b8865d91f028b3f5de73d3d9060 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Tue, 3 May 2022 11:06:04 +0100 Subject: [PATCH 017/113] release: 4.1.0 (#1843) --- CHANGELOG.rst | 17 +++++++++++++++++ flask_appbuilder/__init__.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 50bb1a61f4..4fdf8919cd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,23 @@ Flask-AppBuilder ChangeLog ========================== +Improvements and Bug fixes on 4.1.0 +----------------------------------- + +- docs: add FAB_ADD_SECURITY_API config option (#1840) [Daniel Vaz Gaspar] +- feat: add keycloak auth provider options (#1832) [nilivingston] +- docs: add Azure OAUTH example (#1837) [Mathew Wicks] +- fix: security api (#1831) [Daniel Vaz Gaspar] +- fix: dependency constraints, bump flask-login, flask-wtf (#1838) [Daniel Vaz Gaspar] +- fix: noop user update on Auth db, use set user model (#1834) [Daniel Vaz Gaspar] +- chore: bump postgres to 14 (#1833) [Daniel Vaz Gaspar] +- chore: Update and fix german translation (#1827) [Dosenpfand] +- chore: Enhance is_safe_redirect_url (#1826) [Geido] +- feat: Add CRUD apis for role, permission, user (#1801) [Mayur] +- docs: updated brackets in OAuth Authentication (#1798) [David Berg] +- chore: add Slovenian language (#1828) [dkrat7] +- fix: doc requirements (#1820) [Daniel Vaz Gaspar] + Improvements and Bug fixes on 4.0.0 ----------------------------------- diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 45ed0e6f09..19253b5629 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "4.0.0" +__version__ = "4.1.0" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 From d47352901f191937d1f4e3bcc783ab8accfbc288 Mon Sep 17 00:00:00 2001 From: Sebastian Bernauer Date: Thu, 19 May 2022 14:59:49 +0200 Subject: [PATCH 018/113] fix: Set certificates before reconnecting to LDAP (#1846) --- flask_appbuilder/security/manager.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flask_appbuilder/security/manager.py b/flask_appbuilder/security/manager.py index 4fe3962d46..fc0b8840e5 100644 --- a/flask_appbuilder/security/manager.py +++ b/flask_appbuilder/security/manager.py @@ -1080,12 +1080,6 @@ def auth_user_ldap(self, username, password): try: # LDAP certificate settings - if self.auth_ldap_allow_self_signed: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW) - ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) - elif self.auth_ldap_tls_demand: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND) - ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) if self.auth_ldap_tls_cacertdir: ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.auth_ldap_tls_cacertdir) if self.auth_ldap_tls_cacertfile: @@ -1096,6 +1090,12 @@ def auth_user_ldap(self, username, password): ldap.set_option(ldap.OPT_X_TLS_CERTFILE, self.auth_ldap_tls_certfile) if self.auth_ldap_tls_keyfile: ldap.set_option(ldap.OPT_X_TLS_KEYFILE, self.auth_ldap_tls_keyfile) + if self.auth_ldap_allow_self_signed: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_ALLOW) + ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) + elif self.auth_ldap_tls_demand: + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND) + ldap.set_option(ldap.OPT_X_TLS_NEWCTX, 0) # Initialise LDAP connection con = ldap.initialize(self.auth_ldap_server) From dd759883b677c648f3cb7f84878fa24aa0895c35 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Tue, 24 May 2022 10:52:38 +0100 Subject: [PATCH 019/113] fix: custom security class import, bad cast (#1851) --- flask_appbuilder/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 485e3cccb6..4bd0f6ccb0 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -196,7 +196,7 @@ def init_app(self, app: Flask, session: SessionBase) -> None: if _security_manager_class_name is not None: security_manager_class = dynamic_class_import(_security_manager_class_name) self.security_manager_class = cast( - Type[BaseSecurityManager], security_manager_class + Type["BaseSecurityManager"], security_manager_class ) if self.security_manager_class is None: from flask_appbuilder.security.sqla.manager import SecurityManager From 357c8100e96034c736860737a41c2004aa4e993b Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Wed, 25 May 2022 11:16:23 +0100 Subject: [PATCH 020/113] release: 4.1.1 (#1854) --- CHANGELOG.rst | 6 ++++++ flask_appbuilder/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4fdf8919cd..b701c75e98 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Flask-AppBuilder ChangeLog ========================== +Improvements and Bug fixes on 4.1.1 +----------------------------------- + +- fix: custom security class import, bad cast (#1851) [Daniel Vaz Gaspar] +- fix: Set certificates before reconnecting to LDAP (#1846) [Sebastian Bernauer] + Improvements and Bug fixes on 4.1.0 ----------------------------------- diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 19253b5629..236d8ab55a 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "4.1.0" +__version__ = "4.1.1" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 From bc2ad18868fef46f15506d7e046f0a471e505949 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Wed, 22 Jun 2022 08:46:16 +0100 Subject: [PATCH 021/113] fix(MVC): discard excluded filters from query (#1862) * fix(MVC): discard excluded filters from query * fix get_filter_args * add tests * fix flaky test * remove unnecessary try except --- flask_appbuilder/tests/test_urltools.py | 55 +++++++++++++++++++++++++ flask_appbuilder/urltools.py | 12 +++++- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 flask_appbuilder/tests/test_urltools.py diff --git a/flask_appbuilder/tests/test_urltools.py b/flask_appbuilder/tests/test_urltools.py new file mode 100644 index 0000000000..3dc6033ffd --- /dev/null +++ b/flask_appbuilder/tests/test_urltools.py @@ -0,0 +1,55 @@ +import os + +from flask import Flask +from flask_appbuilder import AppBuilder, SQLA +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.tests.sqla.models import Model1 +from flask_appbuilder.urltools import get_filter_args + +from .base import FABTestCase + + +class FlaskTestCase(FABTestCase): + def setUp(self): + self.app = Flask(__name__) + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config.from_object("flask_appbuilder.tests.config_api") + + self.db = SQLA(self.app) + self.appbuilder = AppBuilder(self.app, self.db.session) + + def test_get_filter_args_allow_one(self): + datamodel = SQLAInterface(Model1) + with self.appbuilder.get_app.test_request_context( + "/users/list?_flt_1_field_string=a" + ): + filters = datamodel.get_filters(["field_string", "field_integer"]) + get_filter_args(filters) + assert filters.values == [["a"]] + + def test_get_filter_args_allow_multiple(self): + datamodel = SQLAInterface(Model1) + with self.appbuilder.get_app.test_request_context( + "/users/list?_flt_1_field_string=a&_flt_1_field_integer=2" + ): + filters = datamodel.get_filters(["field_string", "field_integer"]) + get_filter_args(filters) + assert filters.values in ([["a"], ["2"]], [["2"], ["a"]]) + + def test_get_filter_args_disallow(self): + datamodel = SQLAInterface(Model1) + with self.appbuilder.get_app.test_request_context( + "/users/list?_flt_1_field_float=1.0" + ): + filters = datamodel.get_filters(["field_string", "field_integer"]) + get_filter_args(filters) + assert filters.values == [] + + def test_get_filter_args_invalid_index(self): + datamodel = SQLAInterface(Model1) + with self.appbuilder.get_app.test_request_context( + "/users/list?_flt_a_field_string=a" + ): + filters = datamodel.get_filters(["field_string", "field_integer"]) + get_filter_args(filters) + assert filters.values == [] diff --git a/flask_appbuilder/urltools.py b/flask_appbuilder/urltools.py index 32bd3b161d..f028a17f85 100644 --- a/flask_appbuilder/urltools.py +++ b/flask_appbuilder/urltools.py @@ -1,7 +1,10 @@ +import logging import re from flask import request +log = logging.getLogger(__name__) + class Stack(object): """ @@ -96,7 +99,14 @@ def get_filter_args(filters): request_args = set(request.args) for arg in request_args: re_match = re.findall(r"_flt_(\d)_(.*)", arg) + if not re_match: + continue + filter_index = int(re_match[0][0]) + filter_column = re_match[0][1] + if filter_column not in filters.get_search_filters().keys(): + log.warning("Filter column not allowed") + continue if re_match: filters.add_filter_index( - re_match[0][1], int(re_match[0][0]), request.args.getlist(arg) + filter_column, filter_index, request.args.getlist(arg) ) From 691196dd71340a56695435c1cc2698fda4086855 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Wed, 22 Jun 2022 11:29:06 +0100 Subject: [PATCH 022/113] fix: remove sqlite dbs from examples (#1853) --- examples/issue_789/test.db | Bin 102400 -> 0 bytes examples/quickhowto3/extra.db | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 examples/issue_789/test.db delete mode 100644 examples/quickhowto3/extra.db diff --git a/examples/issue_789/test.db b/examples/issue_789/test.db deleted file mode 100644 index 252b14aa73e0fe96f4e6431adbc1c6ca5147fc00..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 102400 zcmeI53v650dB-nxc@FR4<9yVEx}qq>lx;;)Bw3=Ur{$Le&_I#AJ5UJlkb^N<(=qqCcBo%N8?7&aJh^l(Wqe< zPpSW5^}lpUb>l7tl)5VJuXOvAv9Ik97^Vhwmdg6BREFY-01+SpM1Tko0U|&IhyW2F z0z`la5P^4?KtR
    cMEx8Pa$68stbE<6k$hYv#r?tq(N47wl;K9zwkB0vO)01+Sp zM1Tko0U|&IhyW2F0y~v}KWG}e*49^(&Z?7l{GdWhiS**qN+O&0OBI^QoVyTR$gDat zU>bYYob>u)Vrj|A<-l(mo$EO#n@TTdoMb9r?=y{_tdn!{=MuTx`AjyMYw(&zuZ~)~ zP>KYh%3&L&vH{C9IxwQp2cO3@+A)}0$(;A`VgMTeRxFv!oKLT25=o!Cn7W$E<-Mk9 zM6g^UnQW>vjRCyRucUH|xU;`_*2#xlrV*=-J(p1h)!YT;TL!ekUx6Ps;B)Y4cpN?f z&%sM@4!Y%Aa3{>c_)eW->NgP}0z`la5CI}U1c(3;AOb{y2oM3o2$;KEW&1yfA+(US z{SO%0wEw}}N;ugeQECGV8SBg;0U|&I zhyW2F0z`la5CI}U1a=YuUz^%YK9S9w%_i1zGNLxSi-Frx&Us&}N<5igPde#*PKH%t zF@TBQ7Pa#{lUdK^oLsys4R7^RRQ6@6p$OctYMc$@*wco9CsFLMejcRYde>#zKqMOy^ zn$;G6d^23m+@v=92TrT){H|Q|WImBs7IkVV7gC%4F^t8i+zg%yz-9t!@4sVSWzi=C zazVB8--Txc%C-5_#((=sXK6i~%3lbm9sfdZtSQ%^_WOJB?&x$me?V>ecTBD4SB_3& z{f%ndzaO*Asj~&M%Jj#`X0`nvU%>MP$1a>Go-n}pCUpv6K*v|c2GntYj`_?|V%5P+ zK6N6X{d7L1@%hz3fL(Lxq;n4@;r@S{v<$dk{;NDLr{Pr?hBNS0`9b+z`AHd)mimo= zzl0e$Er(>Me7}4jc;Ex@5VXTLLZW zT(bY$aI?F@{*Pc{$^LJ}#0vXAj62Uo`@aP@vp4O38_SjKe`R{_DFx8*47HnwU7MzO zjs4#gF()fhu|U~eZp4}}rubb!1MYY09F_KeeUsVmsQU9txYH(Iv;P3%y~{;%_!yF}Uk$Nm30c-eseQa|V-0z`la5CI}U1c(3;AOb{y2oM1x zKm>L=0ijL-gioxWSxqfj>I{HAm0U}uasR&#UNPWx^@A=VKm>>Y5g-CYfCvx)B0vO) z01+SpL}14g@S0&)!TztaOj~ICANT)VaL`cy=pq6{fCvx)B0vO)01+SpM1Tko0U|&I zwj!``#1%B`kiBt}7*Od`Q|do%s^j7P1m2uGb60YCXn$@cF*Gu|e`HWyd&lxO$CgvK z-MDumHs%bSNyJ8CvBbog>Y5g-CYfCvx)B2Ym9 z_y4B@uNv^*@I815UVyK`v+!kj8Xkv7;q&lW_!K+{55Rr!A;`g9umpF&Nw@`OU=rfc z3ws~}jo<@QzA0aoFUzmXugPcSm*vy)arvnHy!@>ElzdP=An%hOk~w*oT#|Rllkyfh zBPV5C_R2jnA{(Vont?Y}C+H#qM1Tko0U|&IhyW2F0z`la5P^4+0H1anE?1z9PoV}Q z{0OQQ;fGN@5q=1jNBBWh7U2g_-4Q;CYDV~e)IckrKn;fZII0!qW2l}mA4TP1K7z`^ zd>GXo=5bUr%!g0|Ej)%AwD};aW%B`4kIna?a+~*~GMo3Ix^3QzYTEn;)Ic-uK@Em@ zH>wrlU8tTAzaEu`cqb|g@x7?-5Z{AphWKvOKojrK@r^vH!wr0w4%hSRbU4V{br^V? z4oes>kA9RBmyB$}Dc7x-ITOH7(9j>o{X>L%38(2N^Rl$atL& zx|s`uZruN$4!mZ-58!2Z9=;D>hv(ocuqt1XUy!S^51xS}w1Hp#P`)T1kx981o`5^$ z^YUSNr)-1A;FNq$J|s`cCinu(%dg0f$a(39N8qS@MqZLfr5hfGhu|Y{3GRhF48vY% z0=IlkJ|^>WIPe-AfIj(zyjLCw{P3Mz>Y5g-CYfCw0d$L&(~ zXWO*aU}Q?G6*;2S6FIDvM-FLak%L;@kpo)I$fVXl>wc}l@Pt+?Jg(If9@EOhqgq*b zM5{YItkn$1wFX*-vahp3a(kavX7_7#+kIM1yH{(V`39}QP>)tC)UDMM z>e9+X*K1{=POa|HUae+mkJdob?m~QHMal!U zx#iW$ETPqHSz1lYqqUAJ%U`JP30NV>+&ak2LeO2uRH|?n{{G(&l>JYy3?e`ThyW2F z0z`la5CI}U1c(3;AOb|-+9Tk`0|27n|Nl(`-n{lkNFyQwM1Tko0U|&IhyW2F0z`la z5CI}U1Pr%Z2%&%fU&itL|Eur{d;|Usz6yU2e+^G54*-wB@4;`uZ^9?xm*JO`4}g1N z9nx?Xeim+r1?2_cC>(@Q7=Ui*fL7%Pzz}TjU zKuj?78X(3QIt>tG41ETOQHCx9#0W!=0b-b;!vGOy=r2GFF?1IoVhp_nh(U(V0>l7A zUjbqtLstQ!pP{D!(Z|qHfaqoDCqUf5&`p5oVdy15bTf1kAi5a(2oTpZbP*sr8F~m1 zdl@TK(sP+ z3n0P_y#k09hE4&5&Cn-+XlCdVK!g~21Q1OO9Ri3(hW-FV14DNJqMo5Q01;&93_vJ* z8bva61t00Y1 z238;eKdrm~&ciGmf-x9`9@wq?05(AYxcp!FhWws?@VQ{xgxTdGIOigJyazxAF!&(j<(sJ;imIDX0OipUqzhBG5gqHDfEn{O^Mn|=b zjA$7i))J3v85+_Oi)k4g)G{!jW#2w6{ry_{`n2@+YPsPCEj>M2y1TV>b!oZ&dM%xu zTK4YMvS*K$-Mh7PbZCi2wd~rZ<+|&%w6|+%Yts^mXlZTL5)Nx=Y0+ZaTAG`+ghE=H znzS@FYH4WDQeUqn7}NrwMM^D!fEK@Bi_fRU>(wHJ7Ud-nC&%N_f-V45h^cP_l(>}# z041iWrLL~L|L=nD80sHgM1Tko0U|&IhyW2F0z`la5CI}U1c<;jK_KWdjjp4a)#d0^ zI+;zS&nEEve;qtwsFVLsToXg2K@b5VKm>>Y5g-CYfCvx)B0vO)01>#x2z1q%#_8$A z(u%X1$wo8F(UVIlC%xoEXEJN&*7Hub6cJWt|HM`q=T4#hZ^DZPyaC@;LKhJr0z`la z5CI}U1c(3;AOb{y2oM1xutNz5(=@_2W}Rg6f=7L25c;V^+TrRefyRYQ677E@xI+g< z-6jG=fCvx)B0vO)01+SpM1Tko0U|&Iu8Kh6{Qr{%JPS`=RRPLG1c(3;AOb{y2oM1x zKm>>Y5g-CYfC&7=3DhfR{$1i&BERCSCGx4ITy(D2jRbLo6GlU!fQr!whiJ`+8W z&7939*4ET7{YTex`18btl}sA#e+DlU{{G*$;Xl+ZT||Hg5CI}U1c(3;AOb{y2oM1x zKm>@ujw7J{?4SB;f2P|M!fl$$|39lUT`uMR-=zX|0eHi(jgK184u39hk}n1xkS*RX z2JQ^>i?H{8&;8bOe9|-T{uq13ZJVDld+H9j@+$j}=h{fx9y=a)UCiVYXI7o1%z8HG zEM}dvsa!s*zTL>Aw}@()S(u$VH5)xOH9bEY-6BJ@+bfFEsbqBS_^H{OW*7RRUXjbM zC!KVDu{!p~TNh^MZaN;lW%l;&a(W=|H|h<2xvJy=a;fz2wbN))4Y z5pU1NY_rD>hg}z)ieVO-vbPuv_J6aB(c`zCiXJ~bKaWl4Q~6aVdfU{(%*|5^)hQ18 zg~(Qntt$D%!rZZ`h1+qETaF`NE?zZ`VsvR7?+#>!@kqbN#KRkP(0z9Y>xR5n|j_M$XaHCtX1??kf~?+AMA$w}9xW14c}%;GsG zyOzr3)JuQy?v!(WF`HR+t`J>c)vqgL+UCVn+f?<+!-gwfO}H3qXA9#_AD?^A>Dg%a z<(2hCi&b3q(6~Hr)zZCuBwMcHQtg#bDpeGti?7#X_1sQOx{Oph>D-h1z-lV*EGE|T z8694{ycu0~F?NN7hKo{q?9NWteT${ZTFTM;W=bS$mc->#5U8bEQ(_yo6*;R3 z>lfIXlV1PP)_E$~7p<&fyLGPKI&0SHX0NuSUsZ8U(Q;aGozDcg{odF%^RU@g;i0m{ z5|ty_$Ht z9N436t2md)<<4ia$nYW~-4n9Z=N@m*VIQ|o_HQ#`RWS7vsVc^QDe2MgSHHNcG+D(tLa}P61|#3S zGm=nSc6U3`>057|pPf36oVTANy*gGiXH)5_$QiMpU-_ALSxxui71EZnj+)}+ zqS`R5oaUvKMEdL&(Z$?nE-WtJRoO%-|78hRwHk21GdIu9+!F0Bl9)Rl?H=rl#(MOE zExo~ND3?8AN<+|X*FItj^S;HW_NpFVv6Q!`UvPUWvduch78Zw!&vtLWbv4;bZv|q* z%L*U5?kX+f;#(;SuU*6#xy>Tp_Gz%)L|{SnlI-Vi)A#b*7~Xg`T21{~V549PC<5h& za^=0db`<4YS38v0;9&K`@hZkL=6;*^<#I3dqoo{T8|^NS-PPsVxKNsr&36tptj$1O g?YK6Rew+iFsN?pru5H%iW;bd+#+k~1x6p|HKZB88R{#J2 diff --git a/examples/quickhowto3/extra.db b/examples/quickhowto3/extra.db deleted file mode 100644 index e69de29bb2..0000000000 From e0e94acbfcea23866560454ce12fe7204472496d Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 23 Jun 2022 09:48:00 +0100 Subject: [PATCH 023/113] release: 4.1.2 (#1865) --- CHANGELOG.rst | 6 ++++++ flask_appbuilder/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b701c75e98..4687f8e419 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,12 @@ Flask-AppBuilder ChangeLog ========================== +Improvements and Bug fixes on 4.1.2 +----------------------------------- + +- fix: remove sqlite dbs from examples (#1853) [Daniel Vaz Gaspar] +- fix(MVC): discard excluded filters from query (#1862) [Daniel Vaz Gaspar] + Improvements and Bug fixes on 4.1.1 ----------------------------------- diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index 236d8ab55a..b48f0fe526 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "4.1.1" +__version__ = "4.1.2" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 From 54f8a7b54f939af8a7ccab36874f2472c75d492a Mon Sep 17 00:00:00 2001 From: Zef Lin Date: Thu, 30 Jun 2022 02:59:15 -0700 Subject: [PATCH 024/113] fix: populating permission and vm instead of just setting the id (#1874) --- flask_appbuilder/security/sqla/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/security/sqla/manager.py b/flask_appbuilder/security/sqla/manager.py index bdd43ebebe..653073053b 100755 --- a/flask_appbuilder/security/sqla/manager.py +++ b/flask_appbuilder/security/sqla/manager.py @@ -573,7 +573,7 @@ def add_permission_view_menu(self, permission_name, view_menu_name): vm = self.add_view_menu(view_menu_name) perm = self.add_permission(permission_name) pv = self.permissionview_model() - pv.view_menu_id, pv.permission_id = vm.id, perm.id + pv.view_menu, pv.permission = vm, perm try: self.get_session.add(pv) self.get_session.commit() From 93a446cbac7e5280189b413f4aec86703923cf69 Mon Sep 17 00:00:00 2001 From: Dosenpfand Date: Tue, 5 Jul 2022 13:09:22 +0200 Subject: [PATCH 025/113] chore: Improve german translation (#1872) * Fix german translation for "user registrations" * Improve german translation Co-authored-by: Daniel Vaz Gaspar --- .../translations/de/LC_MESSAGES/messages.po | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/flask_appbuilder/translations/de/LC_MESSAGES/messages.po b/flask_appbuilder/translations/de/LC_MESSAGES/messages.po index 09fe15ddac..bc81e5960b 100644 --- a/flask_appbuilder/translations/de/LC_MESSAGES/messages.po +++ b/flask_appbuilder/translations/de/LC_MESSAGES/messages.po @@ -25,7 +25,7 @@ msgstr "Zugriff verweigert" #: flask_appbuilder/fields.py:192 flask_appbuilder/fields.py:242 #, fuzzy msgid "Not a valid choice" -msgstr "Kein gültiger Datumswert" +msgstr "Kein gültiger Wert" #: flask_appbuilder/fieldwidgets.py:153 flask_appbuilder/fieldwidgets.py:172 msgid "Select Value" @@ -122,7 +122,7 @@ msgstr "Enthält" #: flask_appbuilder/models/generic/filters.py:19 msgid "Contains (insensitive)" -msgstr "" +msgstr "Enthält (Groß/Kleinschreibung ignorieren)" #: flask_appbuilder/models/generic/filters.py:25 #: flask_appbuilder/models/mongoengine/filters.py:74 @@ -193,7 +193,7 @@ msgstr "Keine Beziehung" #: flask_appbuilder/security/forms.py:10 msgid "OpenID" -msgstr "openID" +msgstr "OpenID" #: flask_appbuilder/security/forms.py:11 flask_appbuilder/security/forms.py:16 #: flask_appbuilder/security/forms.py:40 flask_appbuilder/security/forms.py:57 @@ -204,7 +204,7 @@ msgstr "Benutzername" #: flask_appbuilder/security/forms.py:12 msgid "Remember me" -msgstr "Erinnere an mich" +msgstr "Angemeldet bleiben" #: flask_appbuilder/security/forms.py:17 flask_appbuilder/security/forms.py:28 #: flask_appbuilder/security/forms.py:44 flask_appbuilder/security/views.py:126 @@ -267,7 +267,7 @@ msgstr "Passwörter müssen übereinstimmen" #: flask_appbuilder/security/forms.py:43 flask_appbuilder/security/forms.py:60 #: flask_appbuilder/security/views.py:128 msgid "Email" -msgstr "Email" +msgstr "E-Mail" #: flask_appbuilder/security/manager.py:487 #: flask_appbuilder/security/views.py:117 @@ -301,7 +301,7 @@ msgstr "Ansichten/Menüs" #: flask_appbuilder/security/manager.py:516 msgid "Permission on Views/Menus" -msgstr "Berechtigung auf Ansichten/Menüs" +msgstr "Berechtigung für Ansichten/Menüs" #: flask_appbuilder/security/registerviews.py:51 msgid "Account activation" @@ -309,13 +309,13 @@ msgstr "Kontoaktivierung" #: flask_appbuilder/security/registerviews.py:55 msgid "Registration sent to your email" -msgstr "Registrierung an Ihre E-Mail-Adresse gesendet" +msgstr "Eine Registrierungsbestätigung wurde an Ihre E-Mail-Adresse gesendet" #: flask_appbuilder/security/registerviews.py:57 msgid "Not possible to register you at the moment, try again later" msgstr "" -"Nicht möglich ist, die Sie im Moment zu registrieren, versuchen Sie es " -"später noch einmal" +"Es ist nicht möglich, Sie im Moment zu registrieren. Versuchen Sie es " +"später erneut." #: flask_appbuilder/security/registerviews.py:59 msgid "Registration not found" @@ -388,7 +388,7 @@ msgstr "Ansicht/Menü" #: flask_appbuilder/security/views.py:67 flask_appbuilder/security/views.py:82 msgid "Reset Password Form" -msgstr "Passwort zurücksetzen Formular" +msgstr "Passwort-Zurücksetzen-Formular" #: flask_appbuilder/security/views.py:69 flask_appbuilder/security/views.py:84 msgid "Password Changed" @@ -460,8 +460,8 @@ msgstr "Geändert von" #: flask_appbuilder/security/views.py:140 msgid "Username valid for authentication on DB or LDAP, unused for OID auth" msgstr "" -"Login gültig für die Authentifizierung auf DB oder LDAP, nicht für OID " -"benutzt" +"Login gültig für die Authentifizierung via DB oder LDAP, nicht für OID " +"genutzt" #: flask_appbuilder/security/views.py:144 msgid "It's not a good policy to remove a user, just make it inactive" @@ -480,7 +480,7 @@ msgid "" "The user role on the application, this will associate with a list of " "permissions" msgstr "" -"Die Benutzerrolle auf der Anwendung, diese wird mit einer Liste von " +"Die Benutzerrolle auf der Anwendung, diese wird mit einer Liste von " "Berechtigungen verknüpft" #: flask_appbuilder/security/views.py:148 @@ -516,7 +516,7 @@ msgstr "Benutzerinfo" #: flask_appbuilder/security/views.py:155 #: flask_appbuilder/security/views.py:165 msgid "Personal Info" -msgstr "Persönliche Infos" +msgstr "Persönliche Informationen" #: examples/extendsecurity/app/sec_views.py:16 #: examples/extendsecurity2/app/sec_views.py:16 @@ -525,7 +525,7 @@ msgstr "Persönliche Infos" #: examples/quickhowto2/app/sec_views.py:17 #: flask_appbuilder/security/views.py:157 msgid "Audit Info" -msgstr "Audit Info" +msgstr "Audit Informationen" #: flask_appbuilder/security/views.py:173 msgid "Your user information" @@ -573,7 +573,7 @@ msgstr "Liste der Registrierungs-Anfragen" #: flask_appbuilder/security/views.py:348 msgid "Show Registration" -msgstr "Show Registration" +msgstr "Registrierung anzeigen" #: flask_appbuilder/security/views.py:358 msgid "Invalid login. Please try again." @@ -629,7 +629,7 @@ msgstr "Seitengröße" #: flask_appbuilder/templates/appbuilder/general/lib.html:83 msgid "Order by" -msgstr "" +msgstr "Sortieren nach" #: examples/issue_169/app/templates/list_angulajs.html:54 #: examples/issue_169/app/templates/list_angulajs.html:110 @@ -673,7 +673,7 @@ msgstr "Eintrag anzeigen" #: flask_appbuilder/templates/appbuilder/general/lib.html:338 msgid "You sure you want to delete this item?" -msgstr "Sie sicher, dass Sie dieses Item löschen möchten?" +msgstr "Sind Sie sicher, dass Sie diesen Eintrag löschen möchten?" #: examples/issue_169/app/templates/list_angulajs.html:53 #: examples/issue_169/app/templates/list_angulajs.html:133 @@ -692,11 +692,11 @@ msgstr "Gruppieren nach Feldern" #: flask_appbuilder/templates/appbuilder/general/model/edit.html:9 #: flask_appbuilder/templates/appbuilder/general/model/show.html:9 msgid "Detail" -msgstr "" +msgstr "Detail" #: flask_appbuilder/templates/appbuilder/general/security/activation.html:7 msgid "Your user is activated you can now proceed to login" -msgstr "Ihr Benutzername aktiviert ist, können Sie nun zur anmelden" +msgstr "Ihr Benutzername ist aktiviert, Sie können sich nun anmelden" #: flask_appbuilder/templates/appbuilder/general/security/login_db.html:18 #: flask_appbuilder/templates/appbuilder/general/security/login_ldap.html:16 @@ -722,11 +722,11 @@ msgstr "Registrieren" #: flask_appbuilder/templates/appbuilder/general/security/login_oauth.html:44 msgid "Please choose one of the following providers:" -msgstr "Bitte wählen Sie eine der folgenden" +msgstr "Bitte wählen Sie eine der folgenden Anbieter:" #: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:89 msgid "Click on your OpenID provider below" -msgstr "Klicken Sie auf Ihre OpenID-Provider" +msgstr "Klicken Sie auf Ihren OpenID-Provider" #: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:101 msgid "Or enter your OpenID here" @@ -734,11 +734,11 @@ msgstr "Oder geben Sie hier Ihre OpenID ein" #: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:105 msgid "Please choose a provider" -msgstr "Bitte wählen sie einen Anbieter" +msgstr "Bitte wählen Sie einen Anbieter" #: flask_appbuilder/templates/appbuilder/general/security/login_oid.html:107 msgid "Enter your OpenID Username" -msgstr "Oder geben Sie hier Ihre OpenID ein" +msgstr "Geben Sie hier Ihre OpenID ein" #: flask_appbuilder/templates/appbuilder/general/security/register_oauth.html:15 msgid "Sign in using:" From 71e78473b99da151549df1fdbf9cf08e7465fc10 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Tue, 5 Jul 2022 12:48:49 +0100 Subject: [PATCH 026/113] docs: add responsible disclosure text to security (#1882) --- docs/security.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/security.rst b/docs/security.rst index b927a02be5..6461e7586c 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -1,6 +1,12 @@ Security ======== +Responsible disclosure +---------------------- + +We want to keep Flask-AppBuilder safe for everyone. If you've discovered a security vulnerability +please report to danielvazgaspar@gmail.com. + Supported Authentication Types ------------------------------ From 449afe47b17298b57272762332f9c96ea6af0449 Mon Sep 17 00:00:00 2001 From: jnahmias Date: Tue, 5 Jul 2022 07:49:27 -0400 Subject: [PATCH 027/113] fix(api): register responses with apispec using components.response() (#1881) responses should be registered with apispec using components.response(); not by directly modifying the _reponses dict, which is internal to the ApiSpec.Components class. Also, handle duplicate response registrations gracefully. --- flask_appbuilder/api/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flask_appbuilder/api/__init__.py b/flask_appbuilder/api/__init__.py index 71cd5743f4..8de8df84df 100644 --- a/flask_appbuilder/api/__init__.py +++ b/flask_appbuilder/api/__init__.py @@ -533,7 +533,10 @@ def add_api_spec(self, api_spec: APISpec) -> None: def add_apispec_components(self, api_spec: APISpec) -> None: for k, v in self.responses.items(): - api_spec.components._responses[k] = v + try: + api_spec.components.response(k, v) + except DuplicateComponentNameError: + pass for k, v in self._apispec_parameter_schemas.items(): try: api_spec.components.schema(k, v) From cc8e3591daa6f3d70342eb2be60414910ea4703d Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Tue, 5 Jul 2022 15:45:43 +0100 Subject: [PATCH 028/113] fix: FAB_INDEX_VIEW type check (#1883) * fix: FAB_INDEX_VIEW type check * fix lint and init of index view --- examples/factoryapp/README.rst | 2 +- examples/factoryapp/app/__init__.py | 24 -------------- examples/factoryapp/app/app.py | 36 +++++++++++++++++++++ examples/factoryapp/app/views.py | 26 +++++---------- examples/factoryapp/config.py | 2 ++ examples/factoryapp/testdata.py | 27 ++++++++-------- flask_appbuilder/base.py | 50 ++++++++++++++++++----------- 7 files changed, 92 insertions(+), 75 deletions(-) create mode 100644 examples/factoryapp/app/app.py diff --git a/examples/factoryapp/README.rst b/examples/factoryapp/README.rst index 57af1a2e99..1bbfa600ed 100644 --- a/examples/factoryapp/README.rst +++ b/examples/factoryapp/README.rst @@ -9,7 +9,7 @@ Create an Admin user and insert test data:: Run it:: - $ export FLASK_APP="app:create_app('config')" + $ export FLASK_APP="app.app:create_app('config')" $ flask fab create-admin $ flask run diff --git a/examples/factoryapp/app/__init__.py b/examples/factoryapp/app/__init__.py index 8eba7004c6..e69de29bb2 100644 --- a/examples/factoryapp/app/__init__.py +++ b/examples/factoryapp/app/__init__.py @@ -1,24 +0,0 @@ -import logging - -from flask import Flask -from flask_appbuilder import AppBuilder, SQLA - -logging.basicConfig(format="%(asctime)s:%(levelname)s:%(name)s:%(message)s") -logging.getLogger().setLevel(logging.DEBUG) - -db = SQLA() -appbuilder = AppBuilder() - - -def create_app(config): - app = Flask(__name__) - with app.app_context(): - app.config.from_object(config) - db.init_app(app) - appbuilder.init_app(app, db.session) - from . import views # noqa - - db.create_all() - appbuilder.post_init() - views.fill_gender() - return app diff --git a/examples/factoryapp/app/app.py b/examples/factoryapp/app/app.py new file mode 100644 index 0000000000..a573509e22 --- /dev/null +++ b/examples/factoryapp/app/app.py @@ -0,0 +1,36 @@ +import logging + +from flask import Flask +from flask_appbuilder import AppBuilder, SQLA + +logging.basicConfig(format="%(asctime)s:%(levelname)s:%(name)s:%(message)s") +logging.getLogger().setLevel(logging.DEBUG) + + +def create_app(config): + app = Flask(__name__) + db = SQLA() + appbuilder = AppBuilder() + with app.app_context(): + app.config.from_object(config) + db.init_app(app) + appbuilder.init_app(app, db.session) + + from .views import ContactModelView, GroupModelView, fill_gender + + appbuilder.add_view( + ContactModelView, + "List Contacts", + icon="fa-envelope", + category="Contacts", + category_icon="fa-envelope", + ) + + appbuilder.add_view( + GroupModelView, "List Groups", icon="fa-folder-open-o", category="Contacts" + ) + + db.create_all() + appbuilder.post_init() + fill_gender() + return app diff --git a/examples/factoryapp/app/views.py b/examples/factoryapp/app/views.py index eb5751eac3..69e49f5a22 100644 --- a/examples/factoryapp/app/views.py +++ b/examples/factoryapp/app/views.py @@ -1,17 +1,17 @@ -from flask_appbuilder import ModelView +from flask import current_app +from flask_appbuilder import ModelView, IndexView from flask_appbuilder.models.sqla.interface import SQLAInterface -from . import appbuilder, db from .models import Contact, ContactGroup, Gender def fill_gender(): try: - db.session.add(Gender(name="Male")) - db.session.add(Gender(name="Female")) - db.session.commit() + current_app.appbuilder.session.add(Gender(name="Male")) + current_app.appbuilder.session.add(Gender(name="Female")) + current_app.appbuilder.session.commit() except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() class ContactModelView(ModelView): @@ -71,20 +71,10 @@ class ContactModelView(ModelView): ] -appbuilder.add_view( - ContactModelView, - "List Contacts", - icon="fa-envelope", - category="Contacts", - category_icon="fa-envelope", -) - - class GroupModelView(ModelView): datamodel = SQLAInterface(ContactGroup) related_views = [ContactModelView] -appbuilder.add_view( - GroupModelView, "List Groups", icon="fa-folder-open-o", category="Contacts" -) +class MyIndexView(IndexView): + index_template = "my_index.html" diff --git a/examples/factoryapp/config.py b/examples/factoryapp/config.py index 12f98beadc..ee6285554c 100644 --- a/examples/factoryapp/config.py +++ b/examples/factoryapp/config.py @@ -18,6 +18,8 @@ # SQLALCHEMY_DATABASE_URI = 'postgresql://scott:tiger@localhost:5432/myapp' # SQLALCHEMY_ECHO = True +FAB_INDEX_VIEW = "app.views.MyIndexView" + BABEL_DEFAULT_LOCALE = "en" BABEL_DEFAULT_FOLDER = "translations" LANGUAGES = { diff --git a/examples/factoryapp/testdata.py b/examples/factoryapp/testdata.py index 7538e732ac..2bdc202a6c 100644 --- a/examples/factoryapp/testdata.py +++ b/examples/factoryapp/testdata.py @@ -1,7 +1,8 @@ from datetime import datetime import random -from app import create_app, db +from flask import current_app +from app.app import create_app from app.models import Contact, ContactGroup, Gender app = create_app("config") @@ -17,19 +18,19 @@ def get_random_name(names_list, size=1): try: - db.session.add(ContactGroup(name="Friends")) - db.session.add(ContactGroup(name="Family")) - db.session.add(ContactGroup(name="Work")) - db.session.commit() + current_app.appbuilder.session.add(ContactGroup(name="Friends")) + current_app.appbuilder.session.add(ContactGroup(name="Family")) + current_app.appbuilder.session.add(ContactGroup(name="Work")) + current_app.appbuilder.session.commit() except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() try: - db.session.add(Gender(name="Male")) - db.session.add(Gender(name="Female")) - db.session.commit() + current_app.appbuilder.session.add(Gender(name="Male")) + current_app.appbuilder.session.add(Gender(name="Female")) + current_app.appbuilder.session.commit() except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() f = open("NAMES.DIC", "rb") names_list = [x.strip() for x in f.readlines()] @@ -50,9 +51,9 @@ def get_random_name(names_list, size=1): month = random.choice(range(1, 12)) day = random.choice(range(1, 28)) c.birthday = datetime(year, month, day) - db.session.add(c) + current_app.appbuilder.session.add(c) try: - db.session.commit() + current_app.appbuilder.session.commit() print("inserted {0}".format(c)) except Exception: - db.session.rollback() + current_app.appbuilder.session.rollback() diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 4bd0f6ccb0..729a634f79 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -30,7 +30,11 @@ DynamicImportType = Union[ - Type["BaseManager"], Type["BaseView"], Type["BaseSecurityManager"], Type[Menu] + Type["BaseManager"], + Type["BaseView"], + Type["BaseSecurityManager"], + Type[Menu], + Type["AbstractViewApi"], ] @@ -93,7 +97,7 @@ def __init__( app: Optional[Flask] = None, session: Optional[SessionBase] = None, menu: Optional[Menu] = None, - indexview: Optional["AbstractViewApi"] = None, + indexview: Optional[Type["AbstractViewApi"]] = None, base_template: str = "appbuilder/baselayout.html", static_folder: str = "static/appbuilder", static_url_path: str = "/appbuilder", @@ -121,7 +125,7 @@ def __init__( optional, update permissions flag (Boolean) you can use FAB_UPDATE_PERMS config key also """ - self.baseviews: List["AbstractViewApi"] = [] + self.baseviews: List[Union[Type["AbstractViewApi"], "AbstractViewApi"]] = [] # temporary list that hold addon_managers config key self._addon_managers: List[str] = [] @@ -172,12 +176,11 @@ def init_app(self, app: Flask, session: SessionBase) -> None: "FAB_STATIC_URL_PATH", self.static_url_path ) _index_view = app.config.get("FAB_INDEX_VIEW", None) - if _index_view is not None: - view = dynamic_class_import(_index_view) - if isinstance(view, BaseView): - self.indexview = view + if _index_view: + self.indexview = dynamic_class_import(_index_view) # type: ignore else: - self.indexview = self.indexview or IndexView() + self.indexview = self.indexview or IndexView + _menu = app.config.get("FAB_MENU", None) # Setup Menu @@ -229,7 +232,7 @@ def _init_extension(self, app: Flask) -> None: def post_init(self) -> None: for baseview in self.baseviews: # instantiate the views and add session - self._check_and_init(baseview) + baseview = self._check_and_init(baseview) # Register the views has blueprints if baseview.__class__.__name__ not in self.get_app.blueprints.keys(): self.register_blueprint(baseview) @@ -313,12 +316,11 @@ def _add_global_static(self) -> None: def _add_admin_views(self) -> None: """ - Registers indexview, utilview (back function), babel views and Security views. + Registers indexview, utilview (back function), babel views and Security views. """ if self.indexview: - self.indexview = self._check_and_init(self.indexview) - self.add_view_no_menu(self.indexview) - self.add_view_no_menu(UtilView()) + self._indexview = self.add_view_no_menu(self.indexview) + self.add_view_no_menu(UtilView) self.bm.register_views() self.sm.register_views() self.openapi_manager.register_views() @@ -344,7 +346,9 @@ def _add_addon_views(self) -> None: log.exception(e) log.error(LOGMSG_ERR_FAB_ADDON_PROCESS.format(addon, e)) - def _check_and_init(self, baseview: "AbstractViewApi") -> "AbstractViewApi": + def _check_and_init( + self, baseview: Union[Type["AbstractViewApi"], "AbstractViewApi"] + ) -> "AbstractViewApi": # If class if not instantiated, instantiate it # and add db session from security models. if hasattr(baseview, "datamodel"): @@ -356,7 +360,7 @@ def _check_and_init(self, baseview: "AbstractViewApi") -> "AbstractViewApi": def add_view( self, - baseview: "AbstractViewApi", + baseview: Union[Type["AbstractViewApi"], "AbstractViewApi"], name: str, href: str = "", icon: str = "", @@ -536,7 +540,7 @@ def add_separator( def add_view_no_menu( self, - baseview: "AbstractViewApi", + baseview: Union[Type["AbstractViewApi"], "AbstractViewApi"], endpoint: Optional[str] = None, static_folder: Optional[str] = None, ) -> "AbstractViewApi": @@ -565,7 +569,7 @@ def add_view_no_menu( log.warning(LOGMSG_WAR_FAB_VIEW_EXISTS.format(baseview.__class__.__name__)) return baseview - def add_api(self, baseview: "AbstractViewApi") -> "AbstractViewApi": + def add_api(self, baseview: Type["AbstractViewApi"]) -> "AbstractViewApi": """ Add a BaseApi class or child to AppBuilder @@ -621,9 +625,11 @@ def get_url_for_logout(self) -> str: @property def get_url_for_index(self) -> str: - if self.indexview is None: + if self._indexview is None: return "" - return url_for("%s.%s" % (self.indexview.endpoint, self.indexview.default_view)) + return url_for( + "%s.%s" % (self._indexview.endpoint, self._indexview.default_view) + ) @property def get_url_for_userinfo(self) -> str: @@ -640,8 +646,11 @@ def get_url_for_locale(self, lang: str) -> str: ) def add_permissions(self, update_perms: bool = False) -> None: + from flask_appbuilder.baseviews import AbstractViewApi + if self.update_perms or update_perms: for baseview in self.baseviews: + baseview = cast(AbstractViewApi, baseview) self._add_permission(baseview, update_perms=update_perms) self._add_menu_permissions(update_perms=update_perms) @@ -695,7 +704,10 @@ def _view_exists(self, view: "AbstractViewApi") -> bool: return False def _process_inner_views(self) -> None: + from flask_appbuilder.baseviews import AbstractViewApi + for view in self.baseviews: + view = cast(AbstractViewApi, view) for inner_class in view.get_uninit_inner_views(): for v in self.baseviews: if ( From 40e1e1434cf6630721a971a815960e011fc27d8c Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Tue, 5 Jul 2022 16:11:32 +0100 Subject: [PATCH 029/113] fix: custom menu option (#1884) --- flask_appbuilder/base.py | 4 +- .../tests/templates/custom_index.html | 1 + .../tests/test_custom_indexview.py | 46 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 flask_appbuilder/tests/templates/custom_index.html create mode 100644 flask_appbuilder/tests/test_custom_indexview.py diff --git a/flask_appbuilder/base.py b/flask_appbuilder/base.py index 729a634f79..dc107ecdd8 100644 --- a/flask_appbuilder/base.py +++ b/flask_appbuilder/base.py @@ -186,8 +186,8 @@ def init_app(self, app: Flask, session: SessionBase) -> None: # Setup Menu if _menu is not None: menu = dynamic_class_import(_menu) - if isinstance(menu, Menu): - self.menu = menu + if menu is not None and issubclass(menu, Menu): + self.menu = menu() else: self.menu = self.menu or Menu() diff --git a/flask_appbuilder/tests/templates/custom_index.html b/flask_appbuilder/tests/templates/custom_index.html new file mode 100644 index 0000000000..ac246371b8 --- /dev/null +++ b/flask_appbuilder/tests/templates/custom_index.html @@ -0,0 +1 @@ +This is a custom index view. diff --git a/flask_appbuilder/tests/test_custom_indexview.py b/flask_appbuilder/tests/test_custom_indexview.py new file mode 100644 index 0000000000..235411d3d8 --- /dev/null +++ b/flask_appbuilder/tests/test_custom_indexview.py @@ -0,0 +1,46 @@ +import logging +import os + +from flask_appbuilder import IndexView, SQLA + +from .base import FABTestCase + +log = logging.getLogger(__name__) + + +class CustomIndexView(IndexView): + index_template = "templates/custom_index.html" + + +class FlaskTestCase(FABTestCase): + def setUp(self): + from flask import Flask + from flask_appbuilder import AppBuilder + + self.app = Flask(__name__, template_folder=".") + self.basedir = os.path.abspath(os.path.dirname(__file__)) + self.app.config.from_object("flask_appbuilder.tests.config_api") + self.app.config[ + "FAB_INDEX_VIEW" + ] = "flask_appbuilder.tests.test_custom_indexview.CustomIndexView" + + self.db = SQLA(self.app) + self.appbuilder = AppBuilder(self.app, self.db.session) + + def tearDown(self): + self.appbuilder = None + self.app = None + self.db = None + log.debug("TEAR DOWN") + + def test_custom_indexview(self): + """ + Test custom index view. + """ + uri = "/" + client = self.app.test_client() + rv = client.get(uri) + + self.assertEqual(rv.status_code, 200) + data = rv.data.decode("utf-8") + self.assertIn("This is a custom index view.", data) From 83754b1a4031fde85f0d5d9ce31c85c583a27cf2 Mon Sep 17 00:00:00 2001 From: Dosenpfand Date: Tue, 5 Jul 2022 17:16:03 +0200 Subject: [PATCH 030/113] chore: Bump requirements pillow version, remove PIL from doc (#1873) Co-authored-by: Daniel Vaz Gaspar --- docs/installation.rst | 9 ++------- requirements-extra.txt | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 19935f2439..049c3a754c 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -123,14 +123,9 @@ Flask App Builder dependes on - flask-wtform : Web forms. - flask-Babel : For internationalization. -If you plan to use Image processing or upload, you will need to install PIL:: - - pip install pillow - -or:: - - pip install PIL +If you plan to use Image processing or upload, you will need to install Pillow:: + pip install Pillow Python 2 and 3 Compatibility ---------------------------- diff --git a/requirements-extra.txt b/requirements-extra.txt index 0a9996fdc9..6b2c9a4038 100644 --- a/requirements-extra.txt +++ b/requirements-extra.txt @@ -1,7 +1,7 @@ mongoengine>=0.7.10, <0.7.99 flask-mongoengine==0.7.1 pymongo>=2.8.1, <2.8.99 -Pillow>=7.0.0, <8.0.0 +Pillow~=9.1 cython==0.29.17 mysqlclient==2.0.1 psycopg2-binary==2.8.6 From ccc6292b4e00ec50d04d71924a651ea0feca649a Mon Sep 17 00:00:00 2001 From: Dosenpfand Date: Tue, 5 Jul 2022 17:29:18 +0200 Subject: [PATCH 031/113] fix: Do not render hidden form fields twice (#1848) * Fix german translation for "user registrations" * Exlcude hidden fields from render_field --- .../templates/appbuilder/general/lib.html | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/flask_appbuilder/templates/appbuilder/general/lib.html b/flask_appbuilder/templates/appbuilder/general/lib.html index e36ecee82f..ff1bb5483d 100644 --- a/flask_appbuilder/templates/appbuilder/general/lib.html +++ b/flask_appbuilder/templates/appbuilder/general/lib.html @@ -225,22 +225,18 @@ {% macro render_field(field, begin_sep_label='', end_sep_label='', begin_sep_field='', end_sep_field='') %} - {% if field.id != 'csrf_token' %} - {% if field.type == 'HiddenField' %} - {{ field}} - {% else %} - {{begin_sep_label|safe}} - - {{end_sep_label|safe}} - {{begin_sep_field|safe}} - {{ field(**kwargs)|safe }} - {{ field.description }} - {% endif %} + {% if (field.id != 'csrf_token') and (field.type != 'HiddenField') %} + {{begin_sep_label|safe}} + + {{end_sep_label|safe}} + {{begin_sep_field|safe}} + {{ field(**kwargs)|safe }} + {{ field.description }} {% if field.errors %}
    {% for error in field.errors %} From a538649c9917bf3ab6a5c2123e7240b77fca3af3 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Wed, 6 Jul 2022 10:18:13 +0100 Subject: [PATCH 032/113] release: 4.1.3 (#1886) --- CHANGELOG.rst | 12 ++++++++++++ flask_appbuilder/__init__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4687f8e419..0dd614b0a7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,18 @@ Flask-AppBuilder ChangeLog ========================== +Improvements and Bug fixes on 4.1.3 +----------------------------------- + +- fix: Do not render hidden form fields twice (#1848) [Dosenpfand] +- chore: Bump requirements pillow version, remove PIL from doc (#1873) [Dosenpfand] +- fix: custom menu option (#1884) [Daniel Vaz Gaspar] +- fix: FAB_INDEX_VIEW type check (#1883) [Daniel Vaz Gaspar] +- fix(api): register responses with apispec using components.response() (#1881) [jnahmias] +- docs: add responsible disclosure text to security (#1882) [Daniel Vaz Gaspar] +- chore: Improve german translation (#1872) [Dosenpfand] +- fix: populating permission and vm instead of just setting the id (#1874) [Zef Lin] + Improvements and Bug fixes on 4.1.2 ----------------------------------- diff --git a/flask_appbuilder/__init__.py b/flask_appbuilder/__init__.py index b48f0fe526..f70c106ba9 100644 --- a/flask_appbuilder/__init__.py +++ b/flask_appbuilder/__init__.py @@ -1,5 +1,5 @@ __author__ = "Daniel Vaz Gaspar" -__version__ = "4.1.2" +__version__ = "4.1.3" from .actions import action # noqa: F401 from .api import ModelRestApi # noqa: F401 From 4f0b029bf0504a9d81149bdf8f9cbf45e95db17e Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Wed, 6 Jul 2022 11:17:36 +0100 Subject: [PATCH 033/113] fix: user stats view search (#1887) --- flask_appbuilder/security/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/security/views.py b/flask_appbuilder/security/views.py index a40222e982..d1e51cca44 100644 --- a/flask_appbuilder/security/views.py +++ b/flask_appbuilder/security/views.py @@ -407,7 +407,7 @@ class UserStatsChartView(DirectByChartView): "fail_login_count": lazy_gettext("Failed login count"), } - search_columns = UserModelView.search_columns + search_exclude_columns = UserModelView.search_exclude_columns definitions = [ {"label": "Login Count", "group": "username", "series": ["login_count"]}, From 4ac9bba008e404b9a1e783cd272c81bb8634de3d Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Wed, 6 Jul 2022 11:44:38 +0100 Subject: [PATCH 034/113] release: 4.1.3a (#1888) --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0dd614b0a7..7256c8427b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ Flask-AppBuilder ChangeLog Improvements and Bug fixes on 4.1.3 ----------------------------------- +- fix: user stats view search (#1887) [Daniel Vaz Gaspar] - fix: Do not render hidden form fields twice (#1848) [Dosenpfand] - chore: Bump requirements pillow version, remove PIL from doc (#1873) [Dosenpfand] - fix: custom menu option (#1884) [Daniel Vaz Gaspar] From 4a5f048e2b2206b649aa34ebd4e341aeb59dd099 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 7 Jul 2022 12:05:34 +0100 Subject: [PATCH 035/113] docs: fix oauth example config (#1889) --- examples/oauth/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/oauth/config.py b/examples/oauth/config.py index 115424f6f9..bc22bae356 100644 --- a/examples/oauth/config.py +++ b/examples/oauth/config.py @@ -113,7 +113,7 @@ "remote_app": { "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), - "api_base_url": "https://{}}/realms/master/protocol/openid-connect".format( + "api_base_url": "https://{}/realms/master/protocol/openid-connect".format( os.environ.get("KEYCLOAK_DOMAIN") ), "client_kwargs": {"scope": "email profile"}, @@ -133,7 +133,7 @@ "remote_app": { "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), - "api_base_url": "https://{}}/auth/realms/master/protocol/openid-connect".format( + "api_base_url": "https://{}/auth/realms/master/protocol/openid-connect".format( os.environ.get("KEYCLOAK_DOMAIN") ), "client_kwargs": {"scope": "email profile"}, From 6cc9e0c39afcacf99c31544c824dad207bad3c81 Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Thu, 7 Jul 2022 16:18:03 +0100 Subject: [PATCH 036/113] docs: fix oauth example config (#1890) * docs: fix oauth example config * more fixes --- examples/oauth/config.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/examples/oauth/config.py b/examples/oauth/config.py index bc22bae356..6ae8adb3e1 100644 --- a/examples/oauth/config.py +++ b/examples/oauth/config.py @@ -1,12 +1,6 @@ import os from flask import session -from flask_appbuilder.security.manager import ( - AUTH_OID, - AUTH_REMOTE_USER, - AUTH_DB, - AUTH_LDAP, - AUTH_OAUTH, -) +from flask_appbuilder.security.manager import AUTH_OAUTH basedir = os.path.abspath(os.path.dirname(__file__)) @@ -161,7 +155,7 @@ AUTH_USER_REGISTRATION_ROLE = "Admin" # Self registration role based on user info -AUTH_USER_REGISTRATION_ROLE_JMESPATH = "contains(['alice@example.com', 'celine@example.com'], email) && 'Admin' || 'Public'" +# AUTH_USER_REGISTRATION_ROLE_JMESPATH = "contains(['alice@example.com', 'celine@example.com'], email) && 'Admin' || 'Public'" # Replace users database roles each login with those received from OAUTH/LDAP AUTH_ROLES_SYNC_AT_LOGIN = True @@ -169,8 +163,8 @@ # A mapping from LDAP/OAUTH group names to FAB roles AUTH_ROLES_MAPPING = { # For OAUTH - "USER_GROUP_NAME": ["User"], - "ADMIN_GROUP_NAME": ["Admin"], + # "USER_GROUP_NAME": ["User"], + # "ADMIN_GROUP_NAME": ["Admin"], # For LDAP # "cn=User,ou=groups,dc=example,dc=com": ["User"], # "cn=Admin,ou=groups,dc=example,dc=com": ["Admin"], From 328adb650235d759e38779070c0a91699efff26a Mon Sep 17 00:00:00 2001 From: Daniel Vaz Gaspar Date: Tue, 12 Jul 2022 11:45:00 +0100 Subject: [PATCH 037/113] chore: allow authlib > 1 updated docs (#1891) --- docs/security.rst | 2 ++ examples/oauth/app/security.py | 5 ++-- examples/oauth/app/views.py | 5 ++-- examples/oauth/config.py | 55 ++++++++++++++++------------------ setup.py | 2 +- 5 files changed, 34 insertions(+), 35 deletions(-) diff --git a/docs/security.rst b/docs/security.rst index 6461e7586c..f6ce4bedd1 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -196,6 +196,7 @@ Specify a list of OAUTH_PROVIDERS in **config.py** that you want to allow for yo "request_token_url": None, "access_token_url": "https://accounts.google.com/o/oauth2/token", "authorize_url": "https://accounts.google.com/o/oauth2/auth", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", }, }, { @@ -224,6 +225,7 @@ Specify a list of OAUTH_PROVIDERS in **config.py** that you want to allow for yo "client_kwargs": {"scope": "openid profile email groups"}, "access_token_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/token", "authorize_url": "https://OKTA_DOMAIN.okta.com/oauth2/v1/authorize", + "server_metadata_url": f"https://OKTA_DOMAIN.okta.com/.well-known/openid-configuration", }, }, { diff --git a/examples/oauth/app/security.py b/examples/oauth/app/security.py index ed1077f3f5..3c69f44df8 100644 --- a/examples/oauth/app/security.py +++ b/examples/oauth/app/security.py @@ -1,15 +1,14 @@ -from flask import redirect, session +from flask import session from flask_appbuilder import expose from flask_appbuilder.security.views import AuthOAuthView from flask_appbuilder.security.sqla.manager import SecurityManager class MyAuthOAuthView(AuthOAuthView): - @expose("/logout/") def logout(self): """Delete access token before logging out.""" - session.pop('oauth_token', None) + session.pop("oauth_token", None) return super().logout() diff --git a/examples/oauth/app/views.py b/examples/oauth/app/views.py index 0b7f79fe43..6d92b0cea4 100644 --- a/examples/oauth/app/views.py +++ b/examples/oauth/app/views.py @@ -20,8 +20,9 @@ def form_get(self, form): def form_post(self, form): remote_app = self.appbuilder.sm.oauth_remotes["twitter"] resp = remote_app.post( - "statuses/update.json", data={"status": form.message.data}, - token=remote_app.token + "statuses/update.json", + data={"status": form.message.data}, + token=remote_app.token, ) if resp.status_code != 200: flash("An error occurred", "danger") diff --git a/examples/oauth/config.py b/examples/oauth/config.py index 6ae8adb3e1..427107382c 100644 --- a/examples/oauth/config.py +++ b/examples/oauth/config.py @@ -62,6 +62,7 @@ "request_token_url": None, "access_token_url": "https://accounts.google.com/o/oauth2/token", "authorize_url": "https://accounts.google.com/o/oauth2/auth", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", }, }, { @@ -77,8 +78,12 @@ "resource": os.environ.get("AZURE_APPLICATION_ID"), }, "request_token_url": None, - "access_token_url": "https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/token", - "authorize_url": "https://login.microsoftonline.com/{AZURE_TENANT_ID}/oauth2/authorize", + "access_token_url": f"https://login.microsoftonline.com/" + f"{os.environ.get('AZURE_APPLICATION_ID')}/" + "oauth2/token", + "authorize_url": f"https://login.microsoftonline.com/" + f"{os.environ.get('AZURE_APPLICATION_ID')}/" + f"oauth2/authorize", }, }, { @@ -88,16 +93,14 @@ "remote_app": { "client_id": os.environ.get("OKTA_KEY"), "client_secret": os.environ.get("OKTA_SECRET"), - "api_base_url": "https://{}.okta.com/oauth2/v1/".format( - os.environ.get("OKTA_DOMAIN") - ), + "api_base_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/oauth2/v1/", "client_kwargs": {"scope": "openid profile email groups"}, - "access_token_url": "https://{}.okta.com/oauth2/v1/token".format( - os.environ.get("OKTA_DOMAIN") - ), - "authorize_url": "https://{}.okta.com/oauth2/v1/authorize".format( - os.environ.get("OKTA_DOMAIN") - ), + "access_token_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/" + f"oauth2/v1/token", + "authorize_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/" + f"oauth2/v1/authorize", + "server_metadata_url": f"https://{os.environ.get('OKTA_DOMAIN')}.okta.com/" + f".well-known/openid-configuration", }, }, { @@ -107,16 +110,13 @@ "remote_app": { "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), - "api_base_url": "https://{}/realms/master/protocol/openid-connect".format( - os.environ.get("KEYCLOAK_DOMAIN") - ), + "api_base_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"realms/master/protocol/openid-connect", "client_kwargs": {"scope": "email profile"}, - "access_token_url": "https://{}/realms/master/protocol/openid-connect/token".format( - os.environ.get("KEYCLOAK_DOMAIN") - ), - "authorize_url": "https://{}/realms/master/protocol/openid-connect/auth".format( - os.environ.get("KEYCLOAK_DOMAIN") - ), + "access_token_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"realms/master/protocol/openid-connect/token", + "authorize_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"realms/master/protocol/openid-connect/auth", "request_token_url": None, }, }, @@ -127,16 +127,13 @@ "remote_app": { "client_id": os.environ.get("KEYCLOAK_CLIENT_ID"), "client_secret": os.environ.get("KEYCLOAK_CLIENT_SECRET"), - "api_base_url": "https://{}/auth/realms/master/protocol/openid-connect".format( - os.environ.get("KEYCLOAK_DOMAIN") - ), + "api_base_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"auth/realms/master/protocol/openid-connect", "client_kwargs": {"scope": "email profile"}, - "access_token_url": "https://{}/auth/realms/master/protocol/openid-connect/token".format( - os.environ.get("KEYCLOAK_DOMAIN") - ), - "authorize_url": "https://{}/auth/realms/master/protocol/openid-connect/auth".format( - os.environ.get("KEYCLOAK_DOMAIN") - ), + "access_token_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"auth/realms/master/protocol/openid-connect/token", + "authorize_url": f"https://{os.environ.get('KEYCLOAK_DOMAIN')}/" + f"auth/realms/master/protocol/openid-connect/auth", "request_token_url": None, }, }, diff --git a/setup.py b/setup.py index 3ee95fba06..c87ca929ae 100644 --- a/setup.py +++ b/setup.py @@ -69,7 +69,7 @@ def desc(): ], extras_require={ "jmespath": ["jmespath>=0.9.5"], - "oauth": ["Authlib>=0.14, <1.0.0"], + "oauth": ["Authlib>=0.14, <2.0.0"], "openid": ["Flask-OpenID>=1.2.5, <2"], }, tests_require=["nose>=1.0", "mockldap>=0.3.0"], From c2a1e115c4b97f6fea40e5e9fd6ff262bc83994b Mon Sep 17 00:00:00 2001 From: Sansarun Sukawongviwat Date: Fri, 29 Jul 2022 21:55:52 +0700 Subject: [PATCH 038/113] fix: fix a wrong 'next' URL in javascript (#1897) Co-authored-by: Sansarun Sukawongviwat --- .../templates/appbuilder/general/security/login_oauth.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html b/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html index 21d8a6dd56..50adf0a3ac 100644 --- a/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html +++ b/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html @@ -6,7 +6,7 @@ {% endif %} {% endfor %} {% endmacro %} @@ -66,7 +79,7 @@ {% for action_key in actions %} {% set action = actions.get(action_key) %}
  1. - {{ _(action.text) }} @@ -74,7 +87,7 @@
  2. {% endfor %} - {% endmacro %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/edit.html b/flask_appbuilder/templates/appbuilder/general/model/edit.html index e1764a2e0d..e0f04fc3f0 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/edit.html +++ b/flask_appbuilder/templates/appbuilder/general/model/edit.html @@ -32,5 +32,5 @@ {% endblock %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html b/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html index cf4607c0e0..7b7e3fec63 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html +++ b/flask_appbuilder/templates/appbuilder/general/model/edit_cascade.html @@ -25,5 +25,5 @@ {% endblock %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/search.html b/flask_appbuilder/templates/appbuilder/general/model/search.html index 60b8fe7212..d52f89fc5c 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/search.html +++ b/flask_appbuilder/templates/appbuilder/general/model/search.html @@ -1,3 +1,5 @@ +{% import 'appbuilder/baselib.html' as baselib %} + - - diff --git a/flask_appbuilder/templates/appbuilder/general/model/show.html b/flask_appbuilder/templates/appbuilder/general/model/show.html index a4cd438681..8c2510ca30 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/show.html +++ b/flask_appbuilder/templates/appbuilder/general/model/show.html @@ -34,5 +34,5 @@ {% endblock content %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html b/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html index 7ba5e4b530..087e7fe2ce 100644 --- a/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html +++ b/flask_appbuilder/templates/appbuilder/general/model/show_cascade.html @@ -24,5 +24,5 @@ {% endblock %} {% block add_tail_js %} - + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html b/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html index 59c46da183..e1b79ad846 100644 --- a/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html +++ b/flask_appbuilder/templates/appbuilder/general/security/login_oauth.html @@ -3,16 +3,6 @@ {% block content %} - -
    -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/security/login_oid.html b/flask_appbuilder/templates/appbuilder/general/security/login_oid.html index 61f509622d..9e7a972808 100644 --- a/flask_appbuilder/templates/appbuilder/general/security/login_oid.html +++ b/flask_appbuilder/templates/appbuilder/general/security/login_oid.html @@ -2,128 +2,149 @@ {% extends "appbuilder/base.html" %} {% block content %} +
    +
    +
    +
    +
    {{ title }}
    +
    +
    +
    + {{ form.hidden_tag() }} +
    {{ _("Click on your OpenID provider below") }}:
    +
    + +
    +
    + +
    + {{ form.openid(size = 80, class = "hidden form-control") }} + {% for error in form.errors.get('openid', []) %} + {{ _('Please choose a provider') }} +
    + {% endfor %} + + {{ form.username(size = 80, class = "hidden form-control", autofocus = true) }} +
    +
    +
    +
    + +
    +
    + + {% if appbuilder.sm.auth_user_registration %} + + {{ _('Register') }} + + {% endif %} +
    +
    +
    +
    +
    - + function hideOpenId() { + $("#openid").addClass('hidden'); + $("#label-openid").addClass('hidden'); + $("#openid").val(''); + } + function showOpenId() { + $("#openid").removeClass('hidden'); + $("#label-openid").removeClass('hidden'); + } -
    -
    -
    -
    -
    {{ title }}
    -
    -
    + function set_openid(openid, pr) { + $('.img-select').attr('class', 'img-rounded img-unselect'); + $('#' + pr).attr('class', 'img-rounded img-select'); + if (openid == '') { + hideUsername(); + showOpenId(); + } else { + u = openid.search(''); + if (u != -1) { + showUsername(); + hideOpenId(); + } else { + hideUsername(); + hideOpenId(); + } + } + form = document.forms['login']; + form.elements['openid'].value = openid; + } -
    - {{form.hidden_tag()}} -
    {{_("Click on your OpenID provider below")}}:
    -
    -
    - {% for pr in providers %} - - - - {% endfor %} -
    -
    -
    - -
    - {{ form.openid(size = 80, class = "hidden form-control") }} - {% for error in form.errors.get('openid', []) %} - {{_('Please choose a provider')}}
    - {% endfor %} - - {{ form.username(size = 80, class = "hidden form-control", autofocus = true) }} -
    -
    -
    -
    - -
    -
    - - {% if appbuilder.sm.auth_user_registration %} - - {{_('Register')}} - - {% endif %} + function beforeSubmit() { + openid = $("#openid").val(); + u = openid.search(''); + if (u != -1) { + openid = openid.substr(0, u) + $("#username").val(); + } + } - -
    -
    + //--------------------------------- + // POST FORM to Register User View + //--------------------------------- + function registerUser() { + form = document.forms['login']; + if (form.elements['openid'].value == '') { + alert('Please choose a provider first'); + } else { + form.action = "{{appbuilder.sm.get_url_for_registeruser}}"; + form.submit(); + } + } + {% for pr in providers %} + document.getElementById("btn-oid-provider-{{ pr.name }}") + .addEventListener("click", function () { + set_openid("{{ pr.url | safe }}", "{{ pr.name }}"); + }); + {% endfor %} + document.getElementById("btn-oid-before-submit") + .addEventListener("click", function () { + beforeSubmit() + }); + {% if appbuilder.sm.auth_user_registration %} + document.getElementById("btn-oid-register-user") + .addEventListener("click", function () { + registerUser() + }); + {% endif %} + {% endblock %} diff --git a/flask_appbuilder/templates/appbuilder/general/security/resetpassword.html b/flask_appbuilder/templates/appbuilder/general/security/resetpassword.html deleted file mode 100644 index 6ebf459ab7..0000000000 --- a/flask_appbuilder/templates/appbuilder/general/security/resetpassword.html +++ /dev/null @@ -1,29 +0,0 @@ - -{% extends "appbuilder/base.html" %} -{% import 'appbuilder/general/lib.html' as lib %} - -{% block content %} -{{ lib.render_title(title) }} - -
    - {{form.hidden_tag()}} - - {% for item in edit_columns %} - {% set field = form | get_attr(item) %} - - {{ lib.render_field(field) }} - - {% endfor %} -
    - - - -
    -{% endblock %} - - diff --git a/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html b/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html index 8c4d9904b3..c438b0a495 100644 --- a/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html +++ b/flask_appbuilder/templates/appbuilder/general/widgets/base_list.html @@ -1,3 +1,4 @@ +{% import 'appbuilder/baselib.html' as baselib %} {% import 'appbuilder/general/lib.html' as lib %} {% set can_add = "can_add" | is_item_visible(modelview_name) %} @@ -28,7 +29,7 @@ {{ lib.action_form(actions, modelview_name) }} - - + - + - + diff --git a/flask_appbuilder/templates/appbuilder/init.html b/flask_appbuilder/templates/appbuilder/init.html index d078e5e42a..6ebf8b0409 100644 --- a/flask_appbuilder/templates/appbuilder/init.html +++ b/flask_appbuilder/templates/appbuilder/init.html @@ -33,9 +33,9 @@ {% endblock %} {% block head_js %} - - - + + + {% endblock %} @@ -43,10 +43,10 @@ {% endblock %} {% block tail_js %} - - - - + + + + {% endblock %} {% block add_tail_js %} diff --git a/flask_appbuilder/templates/appbuilder/navbar_menu.html b/flask_appbuilder/templates/appbuilder/navbar_menu.html index 4f20804bee..ab6aa1cd40 100644 --- a/flask_appbuilder/templates/appbuilder/navbar_menu.html +++ b/flask_appbuilder/templates/appbuilder/navbar_menu.html @@ -11,7 +11,7 @@ {% if item1 | is_menu_visible %} {% if item1.childs %}