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,