You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by jh...@apache.org on 2021/11/18 23:25:32 UTC
[airflow] branch main updated: Add FAB base class and set import_name explicitly. (#19667)
This is an automated email from the ASF dual-hosted git repository.
jhtimmins pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 84ea55b Add FAB base class and set import_name explicitly. (#19667)
84ea55b is described below
commit 84ea55bf9de14fa9cfa9be6536ce3aabe581bdb7
Author: James Timmins <ja...@astronomer.io>
AuthorDate: Thu Nov 18 15:25:01 2021 -0800
Add FAB base class and set import_name explicitly. (#19667)
* Add FAB base class and set import_name explicitly.
* Fix linter errors caused by FAB code
* Update airflow/www/extensions/init_appbuilder.py
Co-authored-by: Josh Fell <48...@users.noreply.github.com>
* Update airflow/www/extensions/init_appbuilder.py
Co-authored-by: Josh Fell <48...@users.noreply.github.com>
Co-authored-by: Tzu-ping Chung <ur...@gmail.com>
Co-authored-by: Josh Fell <48...@users.noreply.github.com>
---
airflow/www/extensions/init_appbuilder.py | 624 +++++++++++++++++++++++++++++-
1 file changed, 613 insertions(+), 11 deletions(-)
diff --git a/airflow/www/extensions/init_appbuilder.py b/airflow/www/extensions/init_appbuilder.py
index 036315b..13a9c68 100644
--- a/airflow/www/extensions/init_appbuilder.py
+++ b/airflow/www/extensions/init_appbuilder.py
@@ -15,11 +15,623 @@
# specific language governing permissions and limitations
# under the License.
-from flask_appbuilder import AppBuilder
+# This product contains a modified portion of 'Flask App Builder' developed by Daniel Vaz Gaspar.
+# (https://github.com/dpgaspar/Flask-AppBuilder).
+# Copyright 2013, Daniel Vaz Gaspar
+
+
+import logging
+from functools import reduce
+from typing import Dict
+
+from flask import Blueprint, current_app, url_for
+from flask_appbuilder import __version__
+from flask_appbuilder.api.manager import OpenApiManager
+from flask_appbuilder.babel.manager import BabelManager
+from flask_appbuilder.const import (
+ LOGMSG_ERR_FAB_ADD_PERMISSION_MENU,
+ LOGMSG_ERR_FAB_ADD_PERMISSION_VIEW,
+ LOGMSG_ERR_FAB_ADDON_IMPORT,
+ LOGMSG_ERR_FAB_ADDON_PROCESS,
+ LOGMSG_INF_FAB_ADD_VIEW,
+ LOGMSG_INF_FAB_ADDON_ADDED,
+ LOGMSG_WAR_FAB_VIEW_EXISTS,
+)
+from flask_appbuilder.filters import TemplateFilters
+from flask_appbuilder.menu import Menu, MenuApiManager
+from flask_appbuilder.views import IndexView, UtilView
from airflow import settings
from airflow.configuration import conf
+log = logging.getLogger(__name__)
+
+
+def dynamic_class_import(class_path):
+ """
+ Will dynamically import a class from a string path
+ :param class_path: string with class path
+ :return: class
+ """
+ # Split first occurrence of path
+ try:
+ tmp = class_path.split(".")
+ module_path = ".".join(tmp[0:-1])
+ package = __import__(module_path)
+ return reduce(getattr, tmp[1:], package)
+ except Exception as e:
+ log.exception(e)
+ log.error(LOGMSG_ERR_FAB_ADDON_IMPORT.format(class_path, e))
+
+
+class AirflowAppBuilder:
+ """
+ This is the base class for all the framework.
+ This is where 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
+ app = Flask(__name__)
+ app.config.from_object('config')
+ db = SQLA(app)
+ appbuilder = AppBuilder(app, db.session)
+ 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
+ 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.
+ """
+
+ 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='airflow/main.html',
+ static_folder="static/appbuilder",
+ static_url_path="/appbuilder",
+ security_manager_class=None,
+ update_perms=conf.getboolean('webserver', 'UPDATE_FAB_PERMS'),
+ ):
+ """
+ App-builder constructor.
+
+ :param app:
+ The flask app object
+ :param session:
+ The SQLAlchemy session object
+ :param menu:
+ optional, a previous constructed 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 = {}
+ self.menu = menu
+ self.base_template = base_template
+ self.security_manager_class = security_manager_class
+ self.indexview = indexview
+ self.static_folder = static_folder
+ self.static_url_path = static_url_path
+ self.app = app
+ self.update_perms = update_perms
+ if app is not None:
+ self.init_app(app, session)
+
+ def init_app(self, app, session):
+ """
+ Will initialize the Flask app, supporting the app factory pattern.
+ :param app:
+ :param session: The SQLAlchemy session
+ """
+ app.config.setdefault("APP_NAME", "F.A.B.")
+ app.config.setdefault("APP_THEME", "")
+ app.config.setdefault("APP_ICON", "")
+ app.config.setdefault("LANGUAGES", {"en": {"flag": "gb", "name": "English"}})
+ app.config.setdefault("ADDON_MANAGERS", [])
+ app.config.setdefault("FAB_API_MAX_PAGE_SIZE", 100)
+ app.config.setdefault("FAB_BASE_TEMPLATE", self.base_template)
+ app.config.setdefault("FAB_STATIC_FOLDER", self.static_folder)
+ app.config.setdefault("FAB_STATIC_URL_PATH", self.static_url_path)
+
+ self.app = app
+
+ self.base_template = app.config.get("FAB_BASE_TEMPLATE", self.base_template)
+ self.static_folder = app.config.get("FAB_STATIC_FOLDER", self.static_folder)
+ self.static_url_path = app.config.get("FAB_STATIC_URL_PATH", self.static_url_path)
+ _index_view = app.config.get("FAB_INDEX_VIEW", None)
+ if _index_view is not None:
+ self.indexview = dynamic_class_import(_index_view)
+ else:
+ self.indexview = self.indexview or IndexView
+ _menu = app.config.get("FAB_MENU", None)
+ if _menu is not None:
+ self.menu = dynamic_class_import(_menu)
+ else:
+ self.menu = self.menu or Menu()
+
+ if self.update_perms: # default is True, if False takes precedence from config
+ self.update_perms = app.config.get("FAB_UPDATE_PERMS", True)
+ _security_manager_class_name = app.config.get("FAB_SECURITY_MANAGER_CLASS", None)
+ if _security_manager_class_name is not None:
+ self.security_manager_class = dynamic_class_import(_security_manager_class_name)
+ if self.security_manager_class is None:
+ from flask_appbuilder.security.sqla.manager import SecurityManager
+
+ self.security_manager_class = SecurityManager
+
+ self._addon_managers = app.config["ADDON_MANAGERS"]
+ self.session = session
+ self.sm = self.security_manager_class(self)
+ self.bm = BabelManager(self)
+ self.openapi_manager = OpenApiManager(self)
+ self.menuapi_manager = MenuApiManager(self)
+ self._add_global_static()
+ self._add_global_filters()
+ app.before_request(self.sm.before_request)
+ self._add_admin_views()
+ self._add_addon_views()
+ if self.app:
+ self._add_menu_permissions()
+ else:
+ self.post_init()
+ self._init_extension(app)
+
+ def _init_extension(self, app):
+ app.appbuilder = self
+ if not hasattr(app, "extensions"):
+ app.extensions = {}
+ app.extensions["appbuilder"] = self
+
+ def post_init(self):
+ for baseview in self.baseviews:
+ # instantiate the views and add session
+ 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)
+ # Add missing permissions where needed
+ self.add_permissions()
+
+ @property
+ def get_app(self):
+ """
+ Get current or configured flask app
+ :return: Flask App
+ """
+ if self.app:
+ return self.app
+ else:
+ return current_app
+
+ @property
+ def get_session(self):
+ """
+ Get the current sqlalchemy session.
+ :return: SQLAlchemy Session
+ """
+ return self.session
+
+ @property
+ def app_name(self):
+ """
+ Get the App name
+ :return: String with app name
+ """
+ return self.get_app.config["APP_NAME"]
+
+ @property
+ def app_theme(self):
+ """
+ Get the App theme name
+ :return: String app theme name
+ """
+ return self.get_app.config["APP_THEME"]
+
+ @property
+ def app_icon(self):
+ """
+ Get the App icon location
+ :return: String with relative app icon location
+ """
+ return self.get_app.config["APP_ICON"]
+
+ @property
+ def languages(self):
+ return self.get_app.config["LANGUAGES"]
+
+ @property
+ def version(self):
+ """
+ Get the current F.A.B. version
+ :return: String with the current F.A.B. version
+ """
+ return __version__
+
+ def _add_global_filters(self):
+ self.template_filters = TemplateFilters(self.get_app, self.sm)
+
+ def _add_global_static(self):
+ bp = Blueprint(
+ "appbuilder",
+ 'flask_appbuilder.base',
+ url_prefix="/static",
+ template_folder="templates",
+ static_folder=self.static_folder,
+ static_url_path=self.static_url_path,
+ )
+ self.get_app.register_blueprint(bp)
+
+ def _add_admin_views(self):
+ """Register indexview, utilview (back function), babel views and Security views."""
+ 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):
+ """Register declared addons."""
+ for addon in self._addon_managers:
+ addon_class = dynamic_class_import(addon)
+ if addon_class:
+ # Instantiate manager with appbuilder (self)
+ addon_class = addon_class(self)
+ try:
+ addon_class.pre_process()
+ addon_class.register_views()
+ addon_class.post_process()
+ self.addon_managers[addon] = 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):
+ if hasattr(baseview, 'datamodel'):
+ baseview.datamodel.session = self.session
+ if hasattr(baseview, "__call__"):
+ baseview = baseview()
+ return baseview
+
+ def add_view(
+ self,
+ baseview,
+ name,
+ href="",
+ icon="",
+ label="",
+ category="",
+ category_icon="",
+ category_label="",
+ menu_cond=None,
+ ):
+ """Add your views associated with menus using this method.
+
+ :param baseview:
+ A BaseView type class instantiated or not.
+ This method will instantiate the class for you if needed.
+ :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
+ if non provided default_view from view will be set as href.
+ :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 menu_cond:
+ If a callable, :code:`menu_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.
+ Examples::
+ appbuilder = AppBuilder(app, db)
+ # Register a view, rendering a top menu without icon.
+ appbuilder.add_view(MyModelView(), "My View")
+ # or not instantiated
+ appbuilder.add_view(MyModelView, "My View")
+ # Register a view, a submenu "Other View" from "Other" with a phone icon.
+ appbuilder.add_view(
+ MyOtherModelView,
+ "Other View",
+ icon='fa-phone',
+ category="Others"
+ )
+ # Register a view, with category icon and translation.
+ appbuilder.add_view(
+ YetOtherModelView,
+ "Other View",
+ icon='fa-phone',
+ label=_('Other View'),
+ category="Others",
+ category_icon='fa-envelop',
+ category_label=_('Other View')
+ )
+ # Register a view whose menu item will be conditionally displayed
+ appbuilder.add_view(
+ YourFeatureView,
+ "Your Feature",
+ icon='fa-feature',
+ label=_('Your Feature'),
+ menu_cond=lambda: is_feature_enabled("your-feature"),
+ )
+ # Add a link
+ appbuilder.add_link("google", href="www.google.com", icon = "fa-google-plus")
+ """
+ baseview = self._check_and_init(baseview)
+ log.info(LOGMSG_INF_FAB_ADD_VIEW.format(baseview.__class__.__name__, name))
+
+ if not self._view_exists(baseview):
+ baseview.appbuilder = self
+ self.baseviews.append(baseview)
+ self._process_inner_views()
+ if self.app:
+ self.register_blueprint(baseview)
+ self._add_permission(baseview)
+ self.add_link(
+ name=name,
+ href=href,
+ icon=icon,
+ label=label,
+ category=category,
+ category_icon=category_icon,
+ category_label=category_label,
+ baseview=baseview,
+ cond=menu_cond,
+ )
+ return baseview
+
+ 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.
+ """
+ self.menu.add_link(
+ name=name,
+ href=href,
+ icon=icon,
+ label=label,
+ category=category,
+ category_icon=category_icon,
+ category_label=category_label,
+ baseview=baseview,
+ cond=cond,
+ )
+ if self.app:
+ self._add_permissions_menu(name)
+ if category:
+ self._add_permissions_menu(category)
+
+ def add_separator(self, category, cond=None):
+ """
+ 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.
+ """
+ self.menu.add_separator(category, cond=cond)
+
+ def add_view_no_menu(self, baseview, endpoint=None, static_folder=None):
+ """
+ Add your views without creating a menu.
+ :param baseview:
+ A BaseView type class instantiated.
+ """
+ baseview = self._check_and_init(baseview)
+ log.info(LOGMSG_INF_FAB_ADD_VIEW.format(baseview.__class__.__name__, ""))
+
+ if not self._view_exists(baseview):
+ baseview.appbuilder = self
+ self.baseviews.append(baseview)
+ self._process_inner_views()
+ if self.app:
+ self.register_blueprint(baseview, endpoint=endpoint, static_folder=static_folder)
+ self._add_permission(baseview)
+ else:
+ log.warning(LOGMSG_WAR_FAB_VIEW_EXISTS.format(baseview.__class__.__name__))
+ return baseview
+
+ def add_api(self, baseview):
+ """
+ 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):
+ """
+ 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.
+ Remember invoke ONLY AFTER YOU HAVE REGISTERED ALL VIEWS
+ """
+ self.sm.security_cleanup(self.baseviews, self.menu)
+
+ def security_converge(self, dry=False) -> Dict:
+ """
+ This method is useful when you use:
+ - `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
+ :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)
+
+ @property
+ def get_url_for_login(self):
+ return url_for(f"{self.sm.auth_view.endpoint}.login")
+
+ @property
+ def get_url_for_logout(self):
+ return url_for(f"{self.sm.auth_view.endpoint}.logout")
+
+ @property
+ def get_url_for_index(self):
+ return url_for(f"{self.indexview.endpoint}.{self.indexview.default_view}")
+
+ @property
+ def get_url_for_userinfo(self):
+ return url_for(f"{self.sm.user_view.endpoint}.userinfo")
+
+ def get_url_for_locale(self, lang):
+ return url_for(
+ f"{self.bm.locale_view.endpoint}.{self.bm.locale_view.default_view}",
+ locale=lang,
+ )
+
+ def add_permissions(self, update_perms=False):
+ 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):
+ if self.update_perms or update_perms:
+ try:
+ self.sm.add_permissions_view(baseview.base_permissions, baseview.class_permission_name)
+ except Exception as e:
+ log.exception(e)
+ log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_VIEW.format(str(e)))
+
+ def _add_permissions_menu(self, name, update_perms=False):
+ if self.update_perms or update_perms:
+ try:
+ self.sm.add_permissions_menu(name)
+ except Exception as e:
+ log.exception(e)
+ log.error(LOGMSG_ERR_FAB_ADD_PERMISSION_MENU.format(str(e)))
+
+ def _add_menu_permissions(self, update_perms=False):
+ if self.update_perms or update_perms:
+ for category in self.menu.get_list():
+ self._add_permissions_menu(category.name, update_perms=update_perms)
+ for item in category.childs:
+ # don't add permission for menu separator
+ if item.name != "-":
+ self._add_permissions_menu(item.name, update_perms=update_perms)
+
+ def register_blueprint(self, baseview, endpoint=None, static_folder=None):
+ self.get_app.register_blueprint(
+ baseview.create_blueprint(self, endpoint=endpoint, static_folder=static_folder)
+ )
+
+ def _view_exists(self, view):
+ for baseview in self.baseviews:
+ if baseview.__class__ == view.__class__:
+ return True
+ return False
+
+ def _process_inner_views(self):
+ for view in self.baseviews:
+ for inner_class in view.get_uninit_inner_views():
+ for v in self.baseviews:
+ if isinstance(v, inner_class) and v not in view.get_init_inner_views():
+ view.get_init_inner_views().append(v)
+
def init_appbuilder(app):
"""Init `Flask App Builder <https://flask-appbuilder.readthedocs.io/en/latest/>`__."""
@@ -33,16 +645,6 @@ def init_appbuilder(app):
not FAB's security manager."""
)
- class AirflowAppBuilder(AppBuilder):
- """Custom class to prevent side effects of the session."""
-
- def _check_and_init(self, baseview):
- if hasattr(baseview, 'datamodel'):
- # Delete sessions if initiated previously to limit side effects. We want to use
- # the current session in the current application.
- baseview.datamodel.session = None
- return super()._check_and_init(baseview)
-
AirflowAppBuilder(
app=app,
session=settings.Session,