mirror of
https://github.com/apache/superset.git
synced 2026-07-11 17:25:31 +00:00
1406 lines
56 KiB
Python
1406 lines
56 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import sys
|
|
from typing import Any, Callable, TYPE_CHECKING
|
|
|
|
import wtforms_json
|
|
from colorama import Fore, Style
|
|
from deprecation import deprecated
|
|
from flask import abort, current_app, Flask, redirect, request, session, url_for
|
|
from flask_appbuilder import expose, IndexView
|
|
from flask_appbuilder.api import safe
|
|
from flask_appbuilder.utils.base import get_safe_redirect
|
|
|
|
# using lazy_gettext since initialization happens prior to the request scope
|
|
# and confuses flask-babel
|
|
from flask_babel import lazy_gettext as _, refresh
|
|
from flask_compress import Compress
|
|
from flask_session import Session
|
|
from sqlalchemy import text
|
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
|
|
|
from superset.commands.database.exceptions import DatabaseInvalidError
|
|
from superset.constants import (
|
|
CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET,
|
|
CHANGE_ME_GUEST_TOKEN_JWT_SECRET,
|
|
CHANGE_ME_SECRET_KEY,
|
|
)
|
|
from superset.databases.utils import make_url_safe
|
|
from superset.extensions import (
|
|
_event_logger,
|
|
APP_DIR,
|
|
appbuilder,
|
|
async_query_manager_factory,
|
|
cache_manager,
|
|
celery_app,
|
|
csrf,
|
|
db,
|
|
encrypted_field_factory,
|
|
feature_flag_manager,
|
|
machine_auth_provider_factory,
|
|
manifest_processor,
|
|
migrate,
|
|
profiling,
|
|
results_backend_manager,
|
|
ssh_manager_factory,
|
|
stats_logger_manager,
|
|
talisman,
|
|
)
|
|
from superset.extensions.context import extension_context
|
|
from superset.security import SupersetSecurityManager
|
|
from superset.semantic_layers.labels import database_connections_menu_label
|
|
from superset.sql.parse import SQLGLOT_DIALECTS
|
|
from superset.superset_typing import FlaskResponse
|
|
from superset.utils.core import is_test, pessimistic_connection_handling
|
|
from superset.utils.decorators import transaction
|
|
from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value
|
|
|
|
if TYPE_CHECKING:
|
|
from superset.app import SupersetApp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|
def __init__(self, app: SupersetApp) -> None:
|
|
super().__init__()
|
|
|
|
self.superset_app = app
|
|
self.config = app.config
|
|
self.manifest: dict[Any, Any] = {}
|
|
self._db_uri_cache: str | None = None # Cache for valid database URIs
|
|
|
|
@deprecated(details="use self.superset_app instead of self.flask_app") # type: ignore
|
|
@property
|
|
def flask_app(self) -> SupersetApp:
|
|
return self.superset_app
|
|
|
|
@property
|
|
def database_uri(self) -> str:
|
|
"""Lazy property for database URI to avoid early config access issues"""
|
|
# If we have a cached valid value, return it
|
|
if self._db_uri_cache is not None:
|
|
return self._db_uri_cache
|
|
|
|
# Try to get the URI from config
|
|
uri = self.config.get("SQLALCHEMY_DATABASE_URI", "")
|
|
|
|
# Check if this is a fallback value that indicates config isn't ready
|
|
if uri and not any(
|
|
fallback in uri.lower()
|
|
for fallback in ["nouser", "nopassword", "nohost", "nodb"]
|
|
):
|
|
# Valid URI - cache it and return
|
|
self._db_uri_cache = uri
|
|
return uri
|
|
|
|
# Return the fallback value without caching
|
|
# This allows retry on next access when config might be ready
|
|
return uri
|
|
|
|
def pre_init(self) -> None:
|
|
"""
|
|
Called before all other init tasks are complete
|
|
"""
|
|
wtforms_json.init()
|
|
|
|
os.makedirs(self.config["DATA_DIR"], exist_ok=True)
|
|
|
|
def post_init(self) -> None:
|
|
"""
|
|
Called after any other init tasks
|
|
"""
|
|
|
|
def configure_celery(self) -> None:
|
|
celery_app.config_from_object(self.config["CELERY_CONFIG"])
|
|
celery_app.set_default()
|
|
superset_app = self.superset_app
|
|
|
|
# Here, we want to ensure that every call into Celery task has an app context
|
|
# setup properly
|
|
task_base = celery_app.Task
|
|
|
|
class AppContextTask(task_base): # type: ignore
|
|
# pylint: disable=too-few-public-methods
|
|
abstract = True
|
|
|
|
# Grab each call into the task and set up an app context
|
|
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
with superset_app.app_context():
|
|
return task_base.__call__(self, *args, **kwargs)
|
|
|
|
celery_app.Task = AppContextTask
|
|
|
|
def init_views(self) -> None:
|
|
#
|
|
# We're doing local imports, as several of them import
|
|
# models which in turn try to import
|
|
# the global Flask app
|
|
#
|
|
# pylint: disable=import-outside-toplevel,too-many-locals,too-many-statements
|
|
from superset.advanced_data_type.api import AdvancedDataTypeRestApi
|
|
from superset.annotation_layers.annotations.api import AnnotationRestApi
|
|
from superset.annotation_layers.api import AnnotationLayerRestApi
|
|
from superset.async_events.api import AsyncEventsRestApi
|
|
from superset.available_domains.api import AvailableDomainsRestApi
|
|
from superset.cachekeys.api import CacheRestApi
|
|
from superset.charts.api import ChartRestApi
|
|
from superset.charts.data.api import ChartDataRestApi
|
|
from superset.css_templates.api import CssTemplateRestApi
|
|
from superset.dashboards.api import DashboardRestApi
|
|
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
|
|
from superset.dashboards.permalink.api import DashboardPermalinkRestApi
|
|
from superset.databases.api import DatabaseRestApi
|
|
from superset.datasets.api import DatasetRestApi
|
|
from superset.datasets.columns.api import DatasetColumnsRestApi
|
|
from superset.datasets.metrics.api import DatasetMetricRestApi
|
|
from superset.datasource.api import DatasourceRestApi
|
|
from superset.embedded.api import EmbeddedDashboardRestApi
|
|
from superset.embedded.view import EmbeddedView
|
|
from superset.explore.api import ExploreRestApi
|
|
from superset.explore.form_data.api import ExploreFormDataRestApi
|
|
from superset.explore.permalink.api import ExplorePermalinkRestApi
|
|
from superset.extensions.view import ExtensionsView
|
|
from superset.importexport.api import ImportExportRestApi
|
|
from superset.queries.api import QueryRestApi
|
|
from superset.queries.saved_queries.api import SavedQueryRestApi
|
|
from superset.reports.api import ReportScheduleRestApi
|
|
from superset.reports.logs.api import ReportExecutionLogRestApi
|
|
from superset.row_level_security.api import RLSRestApi
|
|
from superset.security.api import (
|
|
RoleRestAPI,
|
|
SecurityRestApi,
|
|
UserRegistrationsRestAPI,
|
|
)
|
|
from superset.sqllab.api import SqlLabRestApi
|
|
from superset.sqllab.permalink.api import SqlLabPermalinkRestApi
|
|
from superset.subjects.api import SubjectRestApi
|
|
from superset.tags.api import TagRestApi
|
|
from superset.themes.api import ThemeRestApi
|
|
from superset.views.alerts import AlertView, ReportView
|
|
from superset.views.all_entities import TaggedObjectsModelView
|
|
from superset.views.annotations import AnnotationLayerView
|
|
from superset.views.api import Api
|
|
from superset.views.chart.views import SliceModelView
|
|
from superset.views.core import Superset
|
|
from superset.views.css_templates import CssTemplateModelView
|
|
from superset.views.dashboard.views import (
|
|
Dashboard,
|
|
DashboardModelView,
|
|
)
|
|
from superset.views.database.views import DatabaseView
|
|
from superset.views.datasource.views import DatasetEditor, Datasource
|
|
from superset.views.dynamic_plugins import DynamicPluginsView
|
|
from superset.views.error_handling import set_app_error_handlers
|
|
from superset.views.explore import ExplorePermalinkView, ExploreView
|
|
from superset.views.groups import GroupsListView
|
|
from superset.views.log.api import LogRestApi
|
|
from superset.views.logs import ActionLogView
|
|
from superset.views.pwa_manifest import PwaManifestView
|
|
from superset.views.redirect import RedirectView
|
|
from superset.views.roles import RolesListView
|
|
from superset.views.sql_lab.views import (
|
|
SavedQueryView,
|
|
TableSchemaView,
|
|
TabStateView,
|
|
)
|
|
from superset.views.sqla import (
|
|
RowLevelSecurityView,
|
|
TableModelView,
|
|
)
|
|
from superset.views.sqllab import SqllabView
|
|
from superset.views.tags import TagModelView, TagView
|
|
from superset.views.tasks import TaskModelView
|
|
from superset.views.themes import ThemeModelView
|
|
from superset.views.user_info import UserInfoView
|
|
from superset.views.user_registrations import UserRegistrationsView
|
|
from superset.views.users.api import CurrentUserRestApi, UserRestApi
|
|
from superset.views.users_list import UsersListView
|
|
|
|
set_app_error_handlers(self.superset_app)
|
|
self.register_request_handlers()
|
|
|
|
# Register health blueprint
|
|
from superset.views.health import health_blueprint
|
|
|
|
self.superset_app.register_blueprint(health_blueprint)
|
|
|
|
#
|
|
# Setup API views
|
|
#
|
|
appbuilder.add_api(AnnotationRestApi)
|
|
appbuilder.add_api(AnnotationLayerRestApi)
|
|
appbuilder.add_api(AsyncEventsRestApi)
|
|
appbuilder.add_api(AdvancedDataTypeRestApi)
|
|
appbuilder.add_api(AvailableDomainsRestApi)
|
|
appbuilder.add_api(CacheRestApi)
|
|
appbuilder.add_api(ChartRestApi)
|
|
appbuilder.add_api(ChartDataRestApi)
|
|
appbuilder.add_api(CssTemplateRestApi)
|
|
appbuilder.add_api(ThemeRestApi)
|
|
appbuilder.add_api(CurrentUserRestApi)
|
|
appbuilder.add_api(UserRestApi)
|
|
appbuilder.add_api(DashboardFilterStateRestApi)
|
|
appbuilder.add_api(DashboardPermalinkRestApi)
|
|
appbuilder.add_api(DashboardRestApi)
|
|
appbuilder.add_api(DatabaseRestApi)
|
|
appbuilder.add_api(DatasetRestApi)
|
|
appbuilder.add_api(DatasetColumnsRestApi)
|
|
appbuilder.add_api(DatasetMetricRestApi)
|
|
appbuilder.add_api(DatasourceRestApi)
|
|
appbuilder.add_api(EmbeddedDashboardRestApi)
|
|
appbuilder.add_api(ExploreRestApi)
|
|
appbuilder.add_api(ExploreFormDataRestApi)
|
|
appbuilder.add_api(ExplorePermalinkRestApi)
|
|
appbuilder.add_api(ImportExportRestApi)
|
|
appbuilder.add_api(QueryRestApi)
|
|
appbuilder.add_api(ReportScheduleRestApi)
|
|
appbuilder.add_api(ReportExecutionLogRestApi)
|
|
appbuilder.add_api(RLSRestApi)
|
|
appbuilder.add_api(SavedQueryRestApi)
|
|
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS"):
|
|
from superset.semantic_layers.api import (
|
|
SemanticLayerRestApi,
|
|
SemanticViewRestApi,
|
|
)
|
|
|
|
appbuilder.add_api(SemanticLayerRestApi)
|
|
appbuilder.add_api(SemanticViewRestApi)
|
|
appbuilder.add_api(TagRestApi)
|
|
appbuilder.add_api(SqlLabRestApi)
|
|
appbuilder.add_api(SqlLabPermalinkRestApi)
|
|
appbuilder.add_api(SubjectRestApi)
|
|
appbuilder.add_api(LogRestApi)
|
|
|
|
if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"):
|
|
from superset.extensions.api import ExtensionsRestApi
|
|
|
|
appbuilder.add_api(ExtensionsRestApi)
|
|
|
|
if feature_flag_manager.is_feature_enabled("GLOBAL_TASK_FRAMEWORK"):
|
|
from superset.tasks.api import TaskRestApi
|
|
|
|
appbuilder.add_api(TaskRestApi)
|
|
|
|
#
|
|
# Setup regular views
|
|
#
|
|
app_root = current_app.config["APPLICATION_ROOT"]
|
|
if app_root.endswith("/"):
|
|
app_root = app_root.rstrip("/")
|
|
|
|
# FAB renders this href verbatim in menus and does not apply
|
|
# SCRIPT_NAME at render time, so the application_root has to be baked
|
|
# in at registration. The previous hardcoded `/superset/welcome/`
|
|
# collided with non-`/superset/` deployments AND with the new
|
|
# `Superset.route_base = ""`. `app_root` is normalized above.
|
|
appbuilder.add_link(
|
|
"Home",
|
|
label=_("Home"),
|
|
href=f"{app_root}/welcome/",
|
|
cond=lambda: bool(current_app.config["LOGO_TARGET_PATH"]),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
DatabaseView,
|
|
"Databases",
|
|
label=database_connections_menu_label(),
|
|
icon="fa-database",
|
|
category="Data",
|
|
category_label=_("Data"),
|
|
)
|
|
appbuilder.add_view(
|
|
DashboardModelView,
|
|
"Dashboards",
|
|
label=_("Dashboards"),
|
|
icon="fa-dashboard",
|
|
category="",
|
|
category_icon="",
|
|
)
|
|
appbuilder.add_view(
|
|
SliceModelView,
|
|
"Charts",
|
|
label=_("Charts"),
|
|
icon="fa-bar-chart",
|
|
category="",
|
|
category_icon="",
|
|
)
|
|
|
|
appbuilder.add_link(
|
|
"Datasets",
|
|
label=_("Datasets"),
|
|
href=f"{app_root}/tablemodelview/list/",
|
|
icon="fa-table",
|
|
category="",
|
|
category_icon="",
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
RolesListView,
|
|
"List Roles",
|
|
label=_("List Roles"),
|
|
category="Security",
|
|
category_label=_("Security"),
|
|
menu_cond=lambda: bool(
|
|
current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
|
|
),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
UserRegistrationsView,
|
|
"User Registrations",
|
|
label=_("User Registrations"),
|
|
category="Security",
|
|
category_label=_("Security"),
|
|
menu_cond=lambda: bool(current_app.config["AUTH_USER_REGISTRATION"]),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
UsersListView,
|
|
"List Users",
|
|
label=_("List Users"),
|
|
category="Security",
|
|
category_label=_("Security"),
|
|
menu_cond=lambda: bool(
|
|
current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
|
|
),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
GroupsListView,
|
|
"List Groups",
|
|
label=_("List Groups"),
|
|
category="Security",
|
|
category_label=_("Security"),
|
|
menu_cond=lambda: bool(
|
|
current_app.config.get("SUPERSET_SECURITY_VIEW_MENU", True)
|
|
),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
DynamicPluginsView,
|
|
"Plugins",
|
|
label=_("Plugins"),
|
|
category="Manage",
|
|
category_label=_("Manage"),
|
|
icon="fa-puzzle-piece",
|
|
menu_cond=lambda: feature_flag_manager.is_feature_enabled(
|
|
"DYNAMIC_PLUGINS"
|
|
),
|
|
)
|
|
appbuilder.add_view(
|
|
CssTemplateModelView,
|
|
"CSS Templates",
|
|
label=_("CSS Templates"),
|
|
icon="fa-css3",
|
|
category="Manage",
|
|
category_label=_("Manage"),
|
|
category_icon="",
|
|
menu_cond=lambda: feature_flag_manager.is_feature_enabled("CSS_TEMPLATES"),
|
|
)
|
|
appbuilder.add_view(
|
|
ThemeModelView,
|
|
"Themes",
|
|
href="/theme/list/",
|
|
label=_("Themes"),
|
|
icon="fa-palette",
|
|
category="Manage",
|
|
category_label=_("Manage"),
|
|
category_icon="",
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
ExtensionsView,
|
|
"Extensions",
|
|
label=_("Extensions"),
|
|
category="Manage",
|
|
category_label=_("Manage"),
|
|
menu_cond=lambda: feature_flag_manager.is_feature_enabled(
|
|
"ENABLE_EXTENSIONS"
|
|
),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
TaskModelView,
|
|
"Tasks",
|
|
label=_("Tasks"),
|
|
icon="fa-clock-o",
|
|
category="Manage",
|
|
category_label=_("Manage"),
|
|
menu_cond=lambda: feature_flag_manager.is_feature_enabled(
|
|
"GLOBAL_TASK_FRAMEWORK"
|
|
),
|
|
)
|
|
|
|
#
|
|
# Setup views with no menu
|
|
#
|
|
appbuilder.add_view_no_menu(Api)
|
|
appbuilder.add_view_no_menu(Dashboard)
|
|
appbuilder.add_view_no_menu(Datasource)
|
|
appbuilder.add_view_no_menu(DatasetEditor)
|
|
appbuilder.add_view_no_menu(EmbeddedView)
|
|
appbuilder.add_view_no_menu(ExploreView)
|
|
appbuilder.add_view_no_menu(ExplorePermalinkView)
|
|
appbuilder.add_view_no_menu(SavedQueryView)
|
|
appbuilder.add_view_no_menu(SqllabView)
|
|
appbuilder.add_view_no_menu(Superset)
|
|
appbuilder.add_view_no_menu(TableModelView)
|
|
appbuilder.add_view_no_menu(TableSchemaView)
|
|
appbuilder.add_view_no_menu(TabStateView)
|
|
appbuilder.add_view_no_menu(TaggedObjectsModelView)
|
|
appbuilder.add_view_no_menu(TagView)
|
|
appbuilder.add_view_no_menu(ReportView)
|
|
appbuilder.add_view_no_menu(PwaManifestView)
|
|
appbuilder.add_view_no_menu(RedirectView)
|
|
appbuilder.add_view_no_menu(RoleRestAPI)
|
|
appbuilder.add_view_no_menu(UserInfoView)
|
|
|
|
#
|
|
# Add links
|
|
#
|
|
appbuilder.add_link(
|
|
"SQL Editor",
|
|
label=_("SQL Lab"),
|
|
href=f"{app_root}/sqllab/",
|
|
category_icon="fa-flask",
|
|
icon="fa-flask",
|
|
category="SQL Lab",
|
|
category_label=_("SQL"),
|
|
)
|
|
appbuilder.add_link(
|
|
"Saved Queries",
|
|
label=_("Saved Queries"),
|
|
href=f"{app_root}/savedqueryview/list/",
|
|
icon="fa-save",
|
|
category="SQL Lab",
|
|
category_label=_("SQL"),
|
|
)
|
|
appbuilder.add_link(
|
|
"Query Search",
|
|
label=_("Query History"),
|
|
href=f"{app_root}/sqllab/history/",
|
|
icon="fa-search",
|
|
category_icon="fa-flask",
|
|
category="SQL Lab",
|
|
category_label=_("SQL Lab"),
|
|
)
|
|
appbuilder.add_view(
|
|
TagModelView,
|
|
"Tags",
|
|
label=_("Tags"),
|
|
icon="",
|
|
category_icon="",
|
|
category="Manage",
|
|
menu_cond=lambda: feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"),
|
|
)
|
|
appbuilder.add_api(LogRestApi)
|
|
appbuilder.add_api(UserRegistrationsRestAPI)
|
|
appbuilder.add_view(
|
|
ActionLogView,
|
|
"Action Log",
|
|
label=_("Action Log"),
|
|
category="Security",
|
|
category_label=_("Security"),
|
|
icon="fa-list-ol",
|
|
menu_cond=lambda: (
|
|
self.config["FAB_ADD_SECURITY_VIEWS"]
|
|
and self.config["SUPERSET_LOG_VIEW"]
|
|
),
|
|
)
|
|
appbuilder.add_api(SecurityRestApi)
|
|
#
|
|
# Conditionally setup email views
|
|
#
|
|
|
|
appbuilder.add_view(
|
|
AlertView,
|
|
"Alerts & Report",
|
|
label=_("Alerts & Reports"),
|
|
category="Manage",
|
|
category_label=_("Manage"),
|
|
icon="fa-exclamation-triangle",
|
|
menu_cond=lambda: feature_flag_manager.is_feature_enabled("ALERT_REPORTS"),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
AnnotationLayerView,
|
|
"Annotation Layers",
|
|
label=_("Annotation Layers"),
|
|
href="AnnotationLayerView.list",
|
|
icon="fa-comment",
|
|
category_icon="",
|
|
category="Manage",
|
|
category_label=_("Manage"),
|
|
)
|
|
|
|
appbuilder.add_view(
|
|
RowLevelSecurityView,
|
|
"Row Level Security",
|
|
href="RowLevelSecurityView.list",
|
|
label=_("Row Level Security"),
|
|
category="Security",
|
|
category_label=_("Security"),
|
|
icon="fa-lock",
|
|
)
|
|
|
|
def init_core_dependencies(self) -> None:
|
|
"""Initialize core dependency injection for direct import patterns."""
|
|
from superset.core.api.core_api_injection import (
|
|
initialize_core_api_dependencies,
|
|
)
|
|
from superset.core.mcp.core_mcp_injection import (
|
|
initialize_core_mcp_dependencies,
|
|
)
|
|
|
|
initialize_core_api_dependencies()
|
|
initialize_core_mcp_dependencies()
|
|
|
|
def init_all_dependencies_and_extensions(self) -> None:
|
|
"""
|
|
Initialize all core dependencies and extensions.
|
|
|
|
Core dependencies are always initialized.
|
|
Extensions are only initialized if the ENABLE_EXTENSIONS feature flag
|
|
is enabled.
|
|
"""
|
|
# Always initialize core dependencies
|
|
self.init_core_dependencies()
|
|
|
|
# Conditionally initialize extensions based on feature flag
|
|
if feature_flag_manager.is_feature_enabled("ENABLE_EXTENSIONS"):
|
|
self.init_extensions()
|
|
|
|
def init_extensions(self) -> None:
|
|
from superset.extensions.utils import (
|
|
eager_import,
|
|
get_extensions,
|
|
install_in_memory_importer,
|
|
)
|
|
|
|
try:
|
|
extensions = get_extensions()
|
|
except Exception: # pylint: disable=broad-except # noqa: S110
|
|
# If the db hasn't been initialized yet, an exception will be raised.
|
|
# It's fine to ignore this, as in this case there are no extensions
|
|
# present yet.
|
|
return
|
|
|
|
for extension in extensions.values():
|
|
if backend_files := extension.backend:
|
|
install_in_memory_importer(
|
|
backend_files,
|
|
source_base_path=extension.source_base_path,
|
|
)
|
|
|
|
backend = extension.manifest.backend
|
|
|
|
if backend and backend.entrypoint:
|
|
try:
|
|
with extension_context(extension.manifest):
|
|
eager_import(backend.entrypoint)
|
|
|
|
except Exception: # pylint: disable=broad-except
|
|
# Surface extension-initialization failures through the
|
|
# configured logger (with traceback) so they reach log
|
|
# aggregation, rather than being written to stdout.
|
|
logger.exception(
|
|
"Failed to initialize extension '%s'",
|
|
extension.manifest.id,
|
|
)
|
|
|
|
@staticmethod
|
|
def _remove_continuum_write_listeners() -> None:
|
|
"""Detach SQLAlchemy-Continuum's own write listeners.
|
|
|
|
``make_versioned()`` runs unconditionally at import of
|
|
``superset.extensions`` and registers Continuum's mapper, session,
|
|
and engine listeners — the ones that write shadow rows and
|
|
``version_transaction`` rows on every flush. Skipping only the
|
|
custom baseline/change-record listeners would leave those running,
|
|
so with the kill-switch off the shadow tables would silently keep
|
|
accumulating, contradicting the documented contract.
|
|
|
|
This is deliberately a *targeted subset* of
|
|
``sqlalchemy_continuum.remove_versioning()``: that helper also
|
|
calls ``manager.reset()``, which clears ``version_class_map`` —
|
|
and ``version_class()`` would then silently return the live model
|
|
class, breaking the read-only ``/versions/`` endpoints this flag
|
|
promises to keep working.
|
|
|
|
Idempotent: guarded on a representative listener so repeated app
|
|
initializations in one process (test fixtures) don't raise on
|
|
double-removal.
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
import sqlalchemy as sa
|
|
from sqlalchemy_continuum import versioning_manager
|
|
|
|
# Enforce the master kill-switch first, unconditionally — before the
|
|
# early return below. Every Continuum write listener checks
|
|
# ``manager.options['versioning']`` before doing work (manager.py /
|
|
# unit_of_work.py), so OFF must guarantee this is False even when the
|
|
# core listeners are already detached (so any still-registered custom
|
|
# listener, or a future Continuum listener this detach doesn't know to
|
|
# remove, no-ops). ``version_class()`` reads ``version_class_map`` and
|
|
# ignores this option, so the read-only ``/versions/`` endpoints are
|
|
# unaffected.
|
|
versioning_manager.options["versioning"] = False
|
|
|
|
if not sa.event.contains(
|
|
sa.orm.Mapper, "after_insert", versioning_manager.track_inserts
|
|
):
|
|
return # listeners already detached by a prior init
|
|
versioning_manager.remove_operations_tracking(sa.orm.Mapper)
|
|
versioning_manager.remove_session_tracking(sa.orm.session.Session)
|
|
sa.event.remove(
|
|
sa.engine.Engine,
|
|
"before_execute",
|
|
versioning_manager.track_association_operations,
|
|
)
|
|
sa.event.remove(
|
|
sa.engine.Engine, "rollback", versioning_manager.clear_connection
|
|
)
|
|
sa.event.remove(
|
|
sa.engine.Engine,
|
|
"set_connection_execution_options",
|
|
versioning_manager.track_cloned_connections,
|
|
)
|
|
|
|
# Verify the known write listeners are actually gone. A Continuum
|
|
# upgrade that renamed a handler would make the removals above silently
|
|
# miss, leaving capture half-on while we report "disabled"; surface
|
|
# that rather than booting in a contradictory state.
|
|
if sa.event.contains(
|
|
sa.orm.Mapper, "after_insert", versioning_manager.track_inserts
|
|
):
|
|
logger.warning(
|
|
"versioning: Continuum write listeners still attached after "
|
|
"detach; capture may not be fully disabled. This usually means "
|
|
"the pinned sqlalchemy-continuum version changed how it "
|
|
"registers listeners."
|
|
)
|
|
|
|
@staticmethod
|
|
def _add_continuum_write_listeners() -> None:
|
|
"""Re-attach Continuum's own write listeners — inverse of
|
|
``_remove_continuum_write_listeners``.
|
|
|
|
``make_versioned()`` attaches these at import, so on a normal boot they
|
|
are already present and this is a no-op. It matters only when capture is
|
|
toggled off->on within one process (multi-app / test reentrancy): an
|
|
earlier OFF init detached Continuum's core shadow/transaction writers,
|
|
and the ON path re-registering the custom baseline/change-record
|
|
listeners + flipping ``options['versioning']`` back on is NOT enough on
|
|
its own — without re-attaching these, no shadow rows would be written
|
|
despite capture being reported "enabled". Mirrors ``make_versioned``'s
|
|
registration exactly.
|
|
|
|
Idempotent: guarded on a representative listener so a normal boot (or a
|
|
repeat ON init) doesn't double-register.
|
|
"""
|
|
# pylint: disable=import-outside-toplevel
|
|
import sqlalchemy as sa
|
|
from sqlalchemy_continuum import versioning_manager
|
|
|
|
if sa.event.contains(
|
|
sa.orm.Mapper, "after_insert", versioning_manager.track_inserts
|
|
):
|
|
return # already attached (normal boot, or already on)
|
|
versioning_manager.track_operations(sa.orm.Mapper)
|
|
versioning_manager.track_session(sa.orm.session.Session)
|
|
sa.event.listen(
|
|
sa.engine.Engine,
|
|
"before_execute",
|
|
versioning_manager.track_association_operations,
|
|
)
|
|
sa.event.listen(
|
|
sa.engine.Engine, "rollback", versioning_manager.clear_connection
|
|
)
|
|
sa.event.listen(
|
|
sa.engine.Engine,
|
|
"set_connection_execution_options",
|
|
versioning_manager.track_cloned_connections,
|
|
)
|
|
|
|
def init_versioning(self) -> None:
|
|
"""Register SQLAlchemy-Continuum baseline and change-record listeners.
|
|
|
|
Must be called after all versioned model classes have been imported so
|
|
that VERSIONED_MODELS can be populated and configure_mappers() has run.
|
|
|
|
``ENABLE_VERSIONING_CAPTURE`` (ships default ``False``) gates the two
|
|
before-flush listener registrations. The flag is operational, not
|
|
feature: with it off the infrastructure is inert (no save writes
|
|
shadow rows); flipping it on activates capture. The switch also lets
|
|
an operator who observes a versioning-induced regression (e.g. a
|
|
save-path slowdown attributable to the change-record listener)
|
|
disable capture in ``superset_config.py`` and restart workers — a
|
|
30-second recovery instead of revert-and-redeploy. Shadow tables
|
|
already created by the migration stay; they just stop accumulating
|
|
new rows.
|
|
|
|
The fallback here is ``False`` so that any app-factory path that
|
|
does not load ``superset.config`` (some test factories, embedded
|
|
use) stays inert by default rather than silently enabling capture.
|
|
"""
|
|
if not self.config.get("ENABLE_VERSIONING_CAPTURE", False):
|
|
logger.warning(
|
|
"versioning: ENABLE_VERSIONING_CAPTURE is False; "
|
|
"skipping baseline + change-record listener registration "
|
|
"and detaching Continuum's write listeners. Save-path "
|
|
"capture is disabled; existing shadow tables and "
|
|
"/versions/ endpoints continue to work read-only."
|
|
)
|
|
self._remove_continuum_write_listeners()
|
|
return
|
|
|
|
# Symmetric with the OFF branch's ``options['versioning'] = False``:
|
|
# re-assert it on here so capture is restored even if a prior app
|
|
# init in the same process (multi-app / test reentrancy) flipped the
|
|
# process-global Continuum option off. Without this, an OFF app
|
|
# initialized before an ON app would leave the option False and the
|
|
# baseline listener — which gates on it — would silently write no
|
|
# baselines despite capture being "enabled".
|
|
from sqlalchemy_continuum import versioning_manager
|
|
|
|
versioning_manager.options["versioning"] = True
|
|
|
|
# Re-attach Continuum's core write listeners in case a prior OFF init
|
|
# in this process detached them. No-op on a normal boot, where
|
|
# make_versioned() already attached them at import. Without this, an
|
|
# off->on toggle in one process would leave the shadow/transaction
|
|
# writers detached and capture silently dead despite the flag.
|
|
self._add_continuum_write_listeners()
|
|
|
|
from sqlalchemy.orm import Session # noqa: F401
|
|
from sqlalchemy_continuum import version_class
|
|
|
|
from superset.connectors.sqla.models import SqlaTable
|
|
from superset.models.dashboard import Dashboard
|
|
from superset.models.slice import Slice
|
|
from superset.versioning.baseline import (
|
|
register_baseline_listener,
|
|
VERSIONED_MODELS,
|
|
)
|
|
|
|
# Note: previously this block called ``configure_mappers()`` before
|
|
# importing the snapshot modules, believing their Table declarations
|
|
# needed ``version_transaction`` to exist. That's not actually the
|
|
# case — the snapshot tables reference ``version_transaction.id``
|
|
# only at the DB level (via the migration); the SQLAlchemy Table
|
|
# objects here intentionally declare ``transaction_id`` as a plain
|
|
# ``BigInteger`` without a FK to avoid the resolution dependency.
|
|
# Removing the global ``configure_mappers()`` avoids eagerly
|
|
# resolving relationships in other unrelated models (notably
|
|
# Flask-AppBuilder's AuditMixin on classes like Tag, whose
|
|
# ``created_by`` primaryjoin only resolves under specific class
|
|
# registry states in SQLAlchemy 1.4).
|
|
from superset.versioning.changes import ( # noqa: E402
|
|
register_change_record_listener,
|
|
)
|
|
|
|
# All versioned models — Dashboard / Slice / SqlaTable plus their
|
|
# children (TableColumn / SqlMetric) and the dashboard_slices
|
|
# M2M — go through Continuum's shadow tables. The JSON-snapshot
|
|
# path that previously backed dataset / dashboard child diffs
|
|
# has been removed (full-Continuum spike).
|
|
for model_cls in (Dashboard, Slice, SqlaTable):
|
|
try:
|
|
version_class(model_cls) # ensure Continuum wired this model
|
|
# Dedup guard: VERSIONED_MODELS is module-level state, and
|
|
# test fixtures initialize multiple Superset apps per
|
|
# process — without the check each re-init appends
|
|
# duplicate entries.
|
|
if model_cls not in VERSIONED_MODELS:
|
|
VERSIONED_MODELS.append(model_cls)
|
|
except Exception: # pylint: disable=broad-except
|
|
# Continuum failed to wire versioning for this model. We
|
|
# boot in degraded mode rather than failing startup, but a
|
|
# silent skip would hide that change capture has stopped for
|
|
# the model — so surface it at WARNING with the traceback.
|
|
logger.warning(
|
|
"Versioning is not wired for %s; change capture will be "
|
|
"skipped for it. This usually means Continuum did not "
|
|
"register a version class for the model.",
|
|
model_cls.__name__,
|
|
exc_info=True,
|
|
)
|
|
|
|
register_baseline_listener()
|
|
register_change_record_listener()
|
|
|
|
# Retention pruning runs out-of-band as a scheduled Celery beat
|
|
# task, shipped as a separate stacked PR. The previous
|
|
# synchronous after_commit listener was retired so retention work
|
|
# doesn't add latency to user saves.
|
|
|
|
def init_app_in_ctx(self) -> None:
|
|
"""
|
|
Runs init logic in the context of the app
|
|
"""
|
|
self.configure_fab()
|
|
self.configure_subjects()
|
|
self.configure_url_map_converters()
|
|
self.configure_data_sources()
|
|
self.configure_auth_provider()
|
|
self.configure_async_queries()
|
|
self.configure_ssh_manager()
|
|
self.configure_stats_manager()
|
|
self.configure_task_manager()
|
|
|
|
# Hook that provides administrators a handle on the Flask APP
|
|
# after initialization
|
|
if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]:
|
|
flask_app_mutator(self.superset_app)
|
|
|
|
# Sync configuration to database (themes, etc.)
|
|
# This can be called separately in multi-tenant environments
|
|
self.superset_app.sync_config_to_db()
|
|
|
|
self.init_views()
|
|
|
|
self.init_all_dependencies_and_extensions()
|
|
|
|
# Must run after all versioned models are imported and mappers configured.
|
|
self.init_versioning()
|
|
|
|
@staticmethod
|
|
def _log_config_warning(message: str) -> None:
|
|
top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-"
|
|
bottom_banner = 80 * "-" + "\n" + 80 * "-"
|
|
logger.warning(top_banner)
|
|
logger.warning(message)
|
|
logger.warning(bottom_banner)
|
|
|
|
def check_secret_key(self) -> None:
|
|
secret_key = self.config["SECRET_KEY"]
|
|
# A missing/empty SECRET_KEY is as insecure as the well-known default
|
|
# placeholder: both are treated as insecure and handled identically. An
|
|
# empty key is reachable when a deployment explicitly sets SECRET_KEY to
|
|
# an empty value (the env fallback only substitutes the placeholder).
|
|
if secret_key and secret_key != CHANGE_ME_SECRET_KEY:
|
|
return
|
|
if not secret_key:
|
|
warning = (
|
|
"An empty SECRET_KEY was detected, please use superset_config.py "
|
|
"to set a non-empty value.\n"
|
|
"Use a strong complex alphanumeric string and use a tool to help"
|
|
" you generate \n"
|
|
"a sufficiently random sequence, ex: openssl rand -base64 42 \n"
|
|
"For more info, see: https://superset.apache.org/docs/"
|
|
"configuration/configuring-superset#specifying-a-secret_key"
|
|
)
|
|
else:
|
|
warning = (
|
|
"A Default SECRET_KEY was detected, please use superset_config.py "
|
|
"to override it.\n"
|
|
"Use a strong complex alphanumeric string and use a tool to help"
|
|
" you generate \n"
|
|
"a sufficiently random sequence, ex: openssl rand -base64 42 \n"
|
|
"For more info, see: https://superset.apache.org/docs/"
|
|
"configuration/configuring-superset#specifying-a-secret_key"
|
|
)
|
|
if self.superset_app.debug or self.superset_app.config["TESTING"] or is_test():
|
|
logger.warning("Debug mode identified with insecure secret key")
|
|
self._log_config_warning(warning)
|
|
return
|
|
self._log_config_warning(warning)
|
|
logger.error("Refusing to start due to insecure SECRET_KEY")
|
|
sys.exit(1)
|
|
|
|
def check_guest_token_secret(self) -> None:
|
|
"""Refuse to start with default guest JWT secret when embedding is enabled."""
|
|
if not feature_flag_manager.is_feature_enabled("EMBEDDED_SUPERSET"):
|
|
return
|
|
if (
|
|
self.config.get("GUEST_TOKEN_JWT_SECRET")
|
|
!= CHANGE_ME_GUEST_TOKEN_JWT_SECRET
|
|
):
|
|
return
|
|
self._log_config_warning(
|
|
"EMBEDDED_SUPERSET is enabled but GUEST_TOKEN_JWT_SECRET has not "
|
|
"been changed from its default value.\n"
|
|
"The default value is publicly known and must be replaced before "
|
|
"running in production.\n"
|
|
"Set a strong random value in superset_config.py:\n"
|
|
" GUEST_TOKEN_JWT_SECRET = "
|
|
"'<output of: openssl rand -base64 42>'"
|
|
)
|
|
if self.superset_app.debug or self.superset_app.config["TESTING"] or is_test():
|
|
return
|
|
logger.error(
|
|
"Refusing to start: insecure GUEST_TOKEN_JWT_SECRET "
|
|
"with EMBEDDED_SUPERSET enabled"
|
|
)
|
|
sys.exit(1)
|
|
|
|
def check_async_query_secret(self) -> None:
|
|
"""Refuse to start with the default async JWT secret when GAQ is enabled."""
|
|
if not feature_flag_manager.is_feature_enabled("GLOBAL_ASYNC_QUERIES"):
|
|
return
|
|
if (
|
|
self.config.get("GLOBAL_ASYNC_QUERIES_JWT_SECRET")
|
|
!= CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
|
|
):
|
|
return
|
|
self._log_config_warning(
|
|
"GLOBAL_ASYNC_QUERIES is enabled but GLOBAL_ASYNC_QUERIES_JWT_SECRET "
|
|
"has not been changed from its default value.\n"
|
|
"The default value is publicly known and must be replaced before "
|
|
"running in production.\n"
|
|
"Set a strong random value (at least 32 bytes) in superset_config.py:\n"
|
|
" GLOBAL_ASYNC_QUERIES_JWT_SECRET = "
|
|
"'<output of: openssl rand -base64 42>'"
|
|
)
|
|
if self.superset_app.debug or self.superset_app.config["TESTING"] or is_test():
|
|
return
|
|
logger.error(
|
|
"Refusing to start: insecure GLOBAL_ASYNC_QUERIES_JWT_SECRET "
|
|
"with GLOBAL_ASYNC_QUERIES enabled"
|
|
)
|
|
sys.exit(1)
|
|
|
|
def configure_session(self) -> None:
|
|
if self.config["SESSION_SERVER_SIDE"]:
|
|
Session(self.superset_app)
|
|
|
|
def register_request_handlers(self) -> None:
|
|
"""Register app-level request handlers"""
|
|
from flask import request, Response
|
|
|
|
from superset.security.password_change import (
|
|
register_password_change_enforcement,
|
|
)
|
|
|
|
# Redirect users with a pending forced password change to the reset
|
|
# page (no-op unless ENABLE_FORCE_PASSWORD_CHANGE is enabled).
|
|
register_password_change_enforcement(self.superset_app)
|
|
|
|
@self.superset_app.after_request
|
|
def apply_http_headers(response: Response) -> Response:
|
|
"""Applies the configuration's http headers to all responses"""
|
|
# HTTP_HEADERS is deprecated, this provides backwards compatibility
|
|
response.headers.extend(
|
|
{
|
|
**self.superset_app.config["OVERRIDE_HTTP_HEADERS"],
|
|
**self.superset_app.config["HTTP_HEADERS"],
|
|
}
|
|
)
|
|
|
|
for k, v in self.superset_app.config["DEFAULT_HTTP_HEADERS"].items():
|
|
if k not in response.headers:
|
|
response.headers[k] = v
|
|
|
|
# Allow service worker to control the root scope for PWA file handling
|
|
if (
|
|
request.path.endswith("service-worker.js")
|
|
and "Service-Worker-Allowed" not in response.headers
|
|
):
|
|
response.headers["Service-Worker-Allowed"] = "/"
|
|
|
|
return response
|
|
|
|
@self.superset_app.after_request
|
|
def cleanup_analytics_memory(response: Response) -> Response:
|
|
"""Force garbage collection after each request if feature flag enabled"""
|
|
if feature_flag_manager.is_feature_enabled(
|
|
"FORCE_GARBAGE_COLLECTION_AFTER_EVERY_REQUEST"
|
|
):
|
|
import gc
|
|
|
|
gc.collect()
|
|
return response
|
|
|
|
@self.superset_app.before_request
|
|
def enforce_session_validity() -> Any:
|
|
"""Force logout of sessions invalidated by a per-user epoch."""
|
|
from superset.security.session_invalidation import (
|
|
enforce_session_validity as _enforce,
|
|
)
|
|
|
|
return _enforce()
|
|
|
|
# Stamp the per-user invalidation epoch when an account is disabled,
|
|
# so outstanding sessions are terminated on their next request.
|
|
from superset.security.session_invalidation import (
|
|
register_session_invalidation_events,
|
|
)
|
|
|
|
register_session_invalidation_events(appbuilder.sm.user_model)
|
|
|
|
@self.superset_app.context_processor
|
|
def get_common_bootstrap_data() -> dict[str, Any]:
|
|
# Import here to avoid circular imports
|
|
from superset.utils import json
|
|
from superset.views.base import common_bootstrap_payload
|
|
|
|
def serialize_bootstrap_data() -> str:
|
|
return json.dumps(
|
|
{"common": common_bootstrap_payload()},
|
|
default=json.pessimistic_json_iso_dttm_ser,
|
|
)
|
|
|
|
return {"bootstrap_data": serialize_bootstrap_data}
|
|
|
|
def check_and_warn_database_connection(self) -> None:
|
|
"""Check database connection and warn if unavailable"""
|
|
try:
|
|
with self.superset_app.app_context():
|
|
# Simple connection test
|
|
with db.engine.connect() as connection:
|
|
connection.execute(text("SELECT 1"))
|
|
except Exception:
|
|
db_uri = self.database_uri
|
|
|
|
if db_uri:
|
|
try:
|
|
safe_uri = make_url_safe(db_uri).render_as_string(
|
|
hide_password=True
|
|
)
|
|
except DatabaseInvalidError:
|
|
safe_uri = "<invalid database URI>"
|
|
else:
|
|
safe_uri = "Not configured"
|
|
|
|
print(
|
|
f"{Fore.RED}ERROR: Cannot connect to database {safe_uri}\n"
|
|
f"NOTE: Most CLI commands require a database{Style.RESET_ALL}"
|
|
)
|
|
|
|
def init_app(self) -> None:
|
|
"""
|
|
Main entry point which will delegate to other methods in
|
|
order to fully init the app
|
|
"""
|
|
self.pre_init()
|
|
self.check_secret_key()
|
|
self.configure_session()
|
|
# Configuration of logging must be done first to apply the formatter properly
|
|
self.configure_logging()
|
|
# Configuration of feature_flags must be done first to allow init features
|
|
# conditionally
|
|
self.configure_feature_flags()
|
|
self.check_guest_token_secret()
|
|
self.check_async_query_secret()
|
|
self.configure_db_encrypt()
|
|
self.setup_db()
|
|
|
|
# Check database connection and warn if unavailable
|
|
self.check_and_warn_database_connection()
|
|
|
|
self.configure_celery()
|
|
self.enable_profiling()
|
|
self.setup_event_logger()
|
|
self.setup_bundle_manifest()
|
|
self.register_blueprints()
|
|
self.configure_wtf()
|
|
self.configure_middlewares()
|
|
self.configure_cache()
|
|
self.set_db_default_isolation()
|
|
self.configure_sqlglot_dialects()
|
|
|
|
with self.superset_app.app_context():
|
|
self.init_app_in_ctx()
|
|
|
|
# Registered outside ``init_app_in_ctx`` because the SQLAlchemy
|
|
# event hook attaches to the ``Session`` *class* (a process-wide
|
|
# global), not to a Session instance — it has no dependency on
|
|
# the Flask app context. ``setup_db()`` ran earlier in
|
|
# ``init_app``, so the ``Session`` import has already been
|
|
# initialised by the time we get here.
|
|
self.setup_soft_delete_listener()
|
|
self.post_init()
|
|
|
|
def set_db_default_isolation(self) -> None:
|
|
# This block sets the default isolation level for mysql to READ COMMITTED if not
|
|
# specified in the config. You can set your isolation in the config by using
|
|
# SQLALCHEMY_ENGINE_OPTIONS
|
|
eng_options = self.config["SQLALCHEMY_ENGINE_OPTIONS"] or {}
|
|
isolation_level = eng_options.get("isolation_level")
|
|
set_isolation_level_to = None
|
|
|
|
if not isolation_level:
|
|
backend = make_url_safe(self.database_uri).get_backend_name()
|
|
if backend in ("mysql", "postgresql"):
|
|
set_isolation_level_to = "READ COMMITTED"
|
|
|
|
if set_isolation_level_to:
|
|
logger.debug(
|
|
"Setting database isolation level to %s",
|
|
set_isolation_level_to,
|
|
)
|
|
with self.superset_app.app_context():
|
|
db.engine.execution_options(isolation_level=set_isolation_level_to)
|
|
|
|
def configure_auth_provider(self) -> None:
|
|
machine_auth_provider_factory.init_app(self.superset_app)
|
|
|
|
def configure_ssh_manager(self) -> None:
|
|
ssh_manager_factory.init_app(self.superset_app)
|
|
|
|
def configure_stats_manager(self) -> None:
|
|
stats_logger_manager.init_app(self.superset_app)
|
|
|
|
def setup_event_logger(self) -> None:
|
|
_event_logger["event_logger"] = get_event_logger_from_cfg_value(
|
|
self.superset_app.config.get("EVENT_LOGGER", DBEventLogger())
|
|
)
|
|
|
|
def configure_data_sources(self) -> None:
|
|
# Registering sources
|
|
module_datasource_map = self.config["DEFAULT_MODULE_DS_MAP"]
|
|
module_datasource_map.update(self.config["ADDITIONAL_MODULE_DS_MAP"])
|
|
|
|
# todo(hughhhh): fully remove the datasource config register
|
|
for module_name, class_names in module_datasource_map.items():
|
|
class_names = [str(s) for s in class_names]
|
|
__import__(module_name, fromlist=class_names)
|
|
|
|
def configure_cache(self) -> None:
|
|
cache_manager.init_app(self.superset_app)
|
|
results_backend_manager.init_app(self.superset_app)
|
|
|
|
def configure_feature_flags(self) -> None:
|
|
feature_flag_manager.init_app(self.superset_app)
|
|
|
|
def configure_sqlglot_dialects(self) -> None:
|
|
extensions = self.config["SQLGLOT_DIALECTS_EXTENSIONS"]
|
|
|
|
if callable(extensions):
|
|
extensions = extensions()
|
|
|
|
SQLGLOT_DIALECTS.update(extensions)
|
|
|
|
@transaction()
|
|
def configure_fab(self) -> None:
|
|
if self.config["SILENCE_FAB"]:
|
|
logging.getLogger("flask_appbuilder").setLevel(logging.ERROR)
|
|
|
|
custom_sm = self.config["CUSTOM_SECURITY_MANAGER"] or SupersetSecurityManager
|
|
if not issubclass(custom_sm, SupersetSecurityManager):
|
|
raise Exception( # pylint: disable=broad-exception-raised
|
|
"""Your CUSTOM_SECURITY_MANAGER must now extend SupersetSecurityManager,
|
|
not FAB's security manager.
|
|
See [4565] in UPDATING.md"""
|
|
)
|
|
|
|
appbuilder.indexview = SupersetIndexView
|
|
appbuilder.security_manager_class = custom_sm
|
|
appbuilder.init_app(self.superset_app, db.session)
|
|
|
|
def configure_subjects(self) -> None:
|
|
from superset.subjects.hooks import register_subject_hooks
|
|
|
|
register_subject_hooks()
|
|
|
|
def configure_url_map_converters(self) -> None:
|
|
#
|
|
# Doing local imports here as model importing causes a reference to
|
|
# app.config to be invoked and we need the current_app to have been setup
|
|
#
|
|
# pylint: disable=import-outside-toplevel
|
|
from superset.utils.url_map_converters import (
|
|
ObjectTypeConverter,
|
|
RegexConverter,
|
|
)
|
|
|
|
self.superset_app.url_map.converters["regex"] = RegexConverter
|
|
self.superset_app.url_map.converters["object_type"] = ObjectTypeConverter
|
|
|
|
def configure_middlewares(self) -> None: # noqa: C901
|
|
if self.config["ENABLE_CORS"]:
|
|
# pylint: disable=import-outside-toplevel
|
|
from flask_cors import CORS
|
|
|
|
CORS(self.superset_app, **self.config["CORS_OPTIONS"])
|
|
|
|
if self.config["ENABLE_PROXY_FIX"]:
|
|
self.superset_app.wsgi_app = ProxyFix(
|
|
self.superset_app.wsgi_app, **self.config["PROXY_FIX_CONFIG"]
|
|
)
|
|
|
|
if self.config["ENABLE_CHUNK_ENCODING"]:
|
|
|
|
class ChunkedEncodingFix: # pylint: disable=too-few-public-methods
|
|
def __init__(self, app: Flask) -> None:
|
|
self.app = app
|
|
|
|
def __call__(
|
|
self, environ: dict[str, Any], start_response: Callable[..., Any]
|
|
) -> Any:
|
|
# Setting wsgi.input_terminated tells werkzeug.wsgi to ignore
|
|
# content-length and read the stream till the end.
|
|
if environ.get("HTTP_TRANSFER_ENCODING", "").lower() == "chunked":
|
|
environ["wsgi.input_terminated"] = True
|
|
return self.app(environ, start_response)
|
|
|
|
self.superset_app.wsgi_app = ChunkedEncodingFix(self.superset_app.wsgi_app)
|
|
|
|
if self.config["UPLOAD_FOLDER"]:
|
|
with contextlib.suppress(OSError):
|
|
os.makedirs(self.config["UPLOAD_FOLDER"])
|
|
for middleware in self.config["ADDITIONAL_MIDDLEWARE"]:
|
|
self.superset_app.wsgi_app = middleware(self.superset_app.wsgi_app)
|
|
|
|
# Flask-Compress
|
|
Compress(self.superset_app)
|
|
|
|
# Talisman
|
|
talisman_enabled = self.config["TALISMAN_ENABLED"]
|
|
talisman_config = (
|
|
self.config["TALISMAN_DEV_CONFIG"]
|
|
if self.superset_app.debug or self.config["DEBUG"]
|
|
else self.config["TALISMAN_CONFIG"]
|
|
)
|
|
csp_warning = self.config["CONTENT_SECURITY_POLICY_WARNING"]
|
|
|
|
if talisman_enabled:
|
|
talisman.init_app(self.superset_app, **talisman_config)
|
|
|
|
show_csp_warning = False
|
|
if (
|
|
csp_warning
|
|
and not self.superset_app.debug
|
|
and (
|
|
not talisman_enabled
|
|
or not talisman_config
|
|
or not talisman_config.get("content_security_policy")
|
|
)
|
|
):
|
|
show_csp_warning = True
|
|
|
|
if show_csp_warning:
|
|
logger.warning(
|
|
"We haven't found any Content Security Policy (CSP) defined in "
|
|
"the configurations. Please make sure to configure CSP using the "
|
|
"TALISMAN_ENABLED and TALISMAN_CONFIG keys or any other external "
|
|
"software. Failing to configure CSP have serious security implications. " # noqa: E501
|
|
"Check https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP for more "
|
|
"information. You can disable this warning using the "
|
|
"CONTENT_SECURITY_POLICY_WARNING key."
|
|
)
|
|
|
|
def configure_logging(self) -> None:
|
|
self.config["LOGGING_CONFIGURATOR"].configure_logging(
|
|
self.config, self.superset_app.debug
|
|
)
|
|
|
|
def configure_db_encrypt(self) -> None:
|
|
encrypted_field_factory.init_app(self.superset_app)
|
|
|
|
def setup_db(self) -> None:
|
|
db.init_app(self.superset_app)
|
|
|
|
with self.superset_app.app_context():
|
|
pessimistic_connection_handling(db.engine)
|
|
|
|
migrate.init_app(self.superset_app, db=db, directory=APP_DIR + "/migrations")
|
|
|
|
def setup_soft_delete_listener(self) -> None:
|
|
"""Register the global soft-delete filter on the SQLAlchemy Session.
|
|
|
|
Must be called after ``setup_db()`` so the Session class is
|
|
available. Uses the ``do_orm_execute`` + ``with_loader_criteria``
|
|
pattern recommended by SQLAlchemy maintainer Mike Bayer for
|
|
soft deletion in SQLAlchemy 1.4+:
|
|
https://github.com/sqlalchemy/sqlalchemy/issues/7973#issuecomment-1112561295
|
|
"""
|
|
from sqlalchemy import event
|
|
from sqlalchemy.orm import Session
|
|
|
|
from superset.models.helpers import _add_soft_delete_filter
|
|
|
|
if not event.contains(Session, "do_orm_execute", _add_soft_delete_filter):
|
|
event.listen(Session, "do_orm_execute", _add_soft_delete_filter)
|
|
|
|
def configure_wtf(self) -> None:
|
|
if self.config["WTF_CSRF_ENABLED"]:
|
|
csrf.init_app(self.superset_app)
|
|
csrf_exempt_list = self.config["WTF_CSRF_EXEMPT_LIST"]
|
|
for ex in csrf_exempt_list:
|
|
csrf.exempt(ex)
|
|
|
|
def configure_async_queries(self) -> None:
|
|
if feature_flag_manager.is_feature_enabled("GLOBAL_ASYNC_QUERIES"):
|
|
# In production, check_async_query_secret() already aborts startup when
|
|
# the default secret is present, so this branch is never reached with it.
|
|
# In debug/testing the check only warns, so skip async-query init here to
|
|
# avoid AsyncQueryManager.init_app() hard-failing on the too-short default
|
|
# secret and crashing startup despite the warn-only intent.
|
|
if (
|
|
self.config.get("GLOBAL_ASYNC_QUERIES_JWT_SECRET")
|
|
== CHANGE_ME_GLOBAL_ASYNC_QUERIES_JWT_SECRET
|
|
):
|
|
return
|
|
async_query_manager_factory.init_app(self.superset_app)
|
|
|
|
def configure_task_manager(self) -> None:
|
|
"""Initialize the TaskManager for GTF realtime notifications."""
|
|
if feature_flag_manager.is_feature_enabled("GLOBAL_TASK_FRAMEWORK"):
|
|
from superset.tasks.manager import TaskManager
|
|
|
|
TaskManager.init_app(self.superset_app)
|
|
|
|
def register_blueprints(self) -> None:
|
|
# Register custom blueprints from config
|
|
for bp in self.config["BLUEPRINTS"]:
|
|
try:
|
|
logger.info("Registering blueprint: %s", bp.name)
|
|
self.superset_app.register_blueprint(bp)
|
|
except Exception: # pylint: disable=broad-except
|
|
logger.exception("blueprint registration failed")
|
|
|
|
def setup_bundle_manifest(self) -> None:
|
|
manifest_processor.init_app(self.superset_app)
|
|
|
|
def enable_profiling(self) -> None:
|
|
if self.config["PROFILING"]:
|
|
profiling.init_app(self.superset_app)
|
|
|
|
|
|
class SupersetIndexView(IndexView):
|
|
@expose("/")
|
|
def index(self) -> FlaskResponse:
|
|
return redirect(url_for("Superset.welcome"))
|
|
|
|
@expose("/lang/<string:locale>")
|
|
@safe
|
|
def patch_flask_locale(self, locale: str) -> FlaskResponse:
|
|
"""
|
|
Change user's locale and redirect back to the previous page.
|
|
|
|
Overrides FAB's babel.views.LocaleView so we can use the request
|
|
Referrer as the redirect target, in case our previous page was actually
|
|
served by the frontend (and thus not added to the session's page_history
|
|
stack).
|
|
"""
|
|
if locale not in self.appbuilder.bm.languages:
|
|
abort(404, description="Locale not supported.")
|
|
session["locale"] = locale
|
|
refresh()
|
|
self.update_redirect()
|
|
|
|
if redirect_to := request.headers.get("Referer"):
|
|
return redirect(get_safe_redirect(redirect_to))
|
|
return redirect(self.get_redirect())
|