Files
superset2/superset/views/base.py

709 lines
24 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 functools
import logging
import os
import traceback
from datetime import datetime
from typing import Any, Callable, cast
from babel import Locale
from flask import (
abort,
current_app as app,
g,
redirect,
Response,
session,
url_for,
)
from flask_appbuilder import BaseView, Model, ModelView
from flask_appbuilder.actions import action
from flask_appbuilder.const import AUTH_OAUTH
from flask_appbuilder.forms import DynamicForm
from flask_appbuilder.models.sqla.filters import BaseFilter
from flask_appbuilder.security.sqla.models import User
from flask_appbuilder.widgets import ListWidget
from flask_babel import get_locale, gettext as __
from flask_jwt_extended.exceptions import NoAuthorizationError
from flask_wtf.form import FlaskForm
from sqlalchemy.orm import Query
from wtforms.fields.core import Field, UnboundField
from superset import (
appbuilder,
db,
get_feature_flags,
is_feature_enabled,
security_manager,
)
from superset.connectors.sqla import models
from superset.daos.theme import ThemeDAO
from superset.db_engine_specs import get_available_engine_specs
from superset.db_engine_specs.gsheets import GSheetsEngineSpec
from superset.extensions import cache_manager
from superset.models.core import Theme as ThemeModel
from superset.reports.models import ReportRecipientType
from superset.superset_typing import FlaskResponse
from superset.themes.types import Theme, ThemeMode
from superset.themes.utils import (
is_valid_theme,
)
from superset.utils import core as utils, json
from superset.utils.filters import get_dataset_access_filters
from superset.utils.version import get_version_metadata
from superset.views.error_handling import json_error_response
from .utils import bootstrap_user_data, get_config_value
FRONTEND_CONF_KEYS = (
"SUPERSET_WEBSERVER_TIMEOUT",
"SUPERSET_DASHBOARD_POSITION_DATA_LIMIT",
"SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT",
"SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE",
"ENABLE_JAVASCRIPT_CONTROLS",
"DEFAULT_SQLLAB_LIMIT",
"DEFAULT_VIZ_TYPE",
"SQL_MAX_ROW",
"SUPERSET_WEBSERVER_DOMAINS",
"SQLLAB_SAVE_WARNING_MESSAGE",
"SQLLAB_DEFAULT_DBID",
"DISPLAY_MAX_ROW",
"GLOBAL_ASYNC_QUERIES_TRANSPORT",
"GLOBAL_ASYNC_QUERIES_POLLING_DELAY",
"SQL_VALIDATORS_BY_ENGINE",
"SQLALCHEMY_DOCS_URL",
"SQLALCHEMY_DISPLAY_TEXT",
"GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL",
"DASHBOARD_AUTO_REFRESH_MODE",
"DASHBOARD_AUTO_REFRESH_INTERVALS",
"DASHBOARD_VIRTUALIZATION",
"SCHEDULED_QUERIES",
"EXCEL_EXTENSIONS",
"CSV_EXTENSIONS",
"COLUMNAR_EXTENSIONS",
"ALLOWED_EXTENSIONS",
"SAMPLES_ROW_LIMIT",
"DEFAULT_TIME_FILTER",
"HTML_SANITIZATION",
"HTML_SANITIZATION_SCHEMA_EXTENSIONS",
"WELCOME_PAGE_LAST_TAB",
"VIZ_TYPE_DENYLIST",
"ALERT_REPORTS_DEFAULT_CRON_VALUE",
"ALERT_REPORTS_DEFAULT_RETENTION",
"ALERT_REPORTS_DEFAULT_WORKING_TIMEOUT",
"NATIVE_FILTER_DEFAULT_ROW_LIMIT",
"SUPERSET_CLIENT_RETRY_ATTEMPTS",
"SUPERSET_CLIENT_RETRY_DELAY",
"SUPERSET_CLIENT_RETRY_BACKOFF_MULTIPLIER",
"SUPERSET_CLIENT_RETRY_MAX_DELAY",
"SUPERSET_CLIENT_RETRY_JITTER_MAX",
"SUPERSET_CLIENT_RETRY_STATUS_CODES",
"PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET",
"JWT_ACCESS_CSRF_COOKIE_NAME",
"SQLLAB_QUERY_RESULT_TIMEOUT",
"SYNC_DB_PERMISSIONS_IN_ASYNC_MODE",
"TABLE_VIZ_MAX_ROW_SERVER",
"MAPBOX_API_KEY",
"CSV_STREAMING_ROW_THRESHOLD",
)
logger = logging.getLogger(__name__)
def get_error_msg() -> str:
if app.config.get("SHOW_STACKTRACE"):
error_msg = traceback.format_exc()
else:
error_msg = "FATAL ERROR \n"
error_msg += (
"Stacktrace is hidden. Change the SHOW_STACKTRACE "
"configuration setting to enable it"
)
return error_msg
def json_success(json_msg: str, status: int = 200) -> FlaskResponse:
return Response(json_msg, status=status, mimetype="application/json")
def data_payload_response(payload_json: str, has_error: bool = False) -> FlaskResponse:
status = 400 if has_error else 200
return json_success(payload_json, status=status)
def generate_download_headers(
extension: str, filename: str | None = None
) -> dict[str, Any]:
filename = filename if filename else datetime.now().strftime("%Y%m%d_%H%M%S")
content_disp = f"attachment; filename={filename}.{extension}"
headers = {"Content-Disposition": content_disp}
return headers
def deprecated(
eol_version: str = "5.0.0",
new_target: str | None = None,
) -> Callable[[Callable[..., FlaskResponse]], Callable[..., FlaskResponse]]:
"""
A decorator to set an API endpoint from SupersetView has deprecated.
Issues a log warning
"""
def _deprecated(f: Callable[..., FlaskResponse]) -> Callable[..., FlaskResponse]:
def wraps(self: BaseSupersetView, *args: Any, **kwargs: Any) -> FlaskResponse:
message = (
"%s.%s "
"This API endpoint is deprecated and will be removed in version %s"
)
logger_args = [
self.__class__.__name__,
f.__name__,
eol_version,
]
if new_target:
message += " . Use the following API endpoint instead: %s"
logger_args.append(new_target)
logger.warning(message, *logger_args)
return f(self, *args, **kwargs)
return functools.update_wrapper(wraps, f)
return _deprecated
def api(f: Callable[..., FlaskResponse]) -> Callable[..., FlaskResponse]:
"""
A decorator to label an endpoint as an API. Catches uncaught exceptions and
return the response in the JSON format
"""
def wraps(self: BaseSupersetView, *args: Any, **kwargs: Any) -> FlaskResponse:
try:
return f(self, *args, **kwargs)
except NoAuthorizationError:
logger.warning("Api failed- no authorization", exc_info=True)
return json_error_response(get_error_msg(), status=401)
except Exception as ex: # pylint: disable=broad-except
logger.exception(ex)
return json_error_response(get_error_msg())
return functools.update_wrapper(wraps, f)
class BaseSupersetView(BaseView):
@staticmethod
def json_response(obj: Any, status: int = 200) -> FlaskResponse:
return Response(
json.dumps(obj, default=json.json_int_dttm_ser, ignore_nan=True),
status=status,
mimetype="application/json",
)
def render_app_template(
self,
extra_bootstrap_data: dict[str, Any] | None = None,
entry: str | None = "spa",
**template_kwargs: Any,
) -> FlaskResponse:
"""
Render spa.html template with standardized context including spinner logic.
This centralizes all spa.html rendering to ensure consistent spinner behavior
and reduce code duplication across view methods.
Args:
extra_bootstrap_data: Additional data for frontend bootstrap payload
entry: Entry point name (spa, explore, embedded)
**template_kwargs: Additional template variables
Returns:
Flask response from render_template
"""
context = get_spa_template_context(
entry, extra_bootstrap_data, **template_kwargs
)
return self.render_template("superset/spa.html", **context)
def get_environment_tag() -> dict[str, Any]:
# Whether flask is in debug mode (--debug)
debug = app.config["DEBUG"]
# Getting the configuration option for ENVIRONMENT_TAG_CONFIG
env_tag_config = app.config["ENVIRONMENT_TAG_CONFIG"]
# These are the predefined templates define in the config
env_tag_templates = env_tag_config.get("values")
# This is the environment variable name from which to select the template
# default is SUPERSET_ENV (from FLASK_ENV in previous versions)
env_envvar = env_tag_config.get("variable")
# this is the actual name we want to use
env_name = os.environ.get(env_envvar)
if not env_name or env_name not in env_tag_templates.keys():
env_name = "debug" if debug else None
env_tag = env_tag_templates.get(env_name)
return env_tag or {}
def menu_data(user: User) -> dict[str, Any]:
languages = {
lang: {**appbuilder.languages[lang], "url": appbuilder.get_url_for_locale(lang)}
for lang in appbuilder.languages
}
if callable(brand_text := app.config["LOGO_RIGHT_TEXT"]):
brand_text = brand_text()
# Get centralized version metadata
version_metadata = get_version_metadata()
return {
"menu": appbuilder.menu.get_data(),
"brand": {
"path": app.config["LOGO_TARGET_PATH"] or url_for("Superset.welcome"),
"icon": appbuilder.app_icon,
"alt": appbuilder.app_name,
"tooltip": app.config["LOGO_TOOLTIP"],
"text": brand_text,
},
"environment_tag": get_environment_tag(),
"navbar_right": {
# show the watermark if the default app icon has been overridden
"show_watermark": ("superset-logo-horiz" not in appbuilder.app_icon),
"bug_report_url": app.config["BUG_REPORT_URL"],
"bug_report_icon": app.config["BUG_REPORT_ICON"],
"bug_report_text": app.config["BUG_REPORT_TEXT"],
"documentation_url": app.config["DOCUMENTATION_URL"],
"documentation_icon": app.config["DOCUMENTATION_ICON"],
"documentation_text": app.config["DOCUMENTATION_TEXT"],
"version_string": version_metadata.get("version_string"),
"version_sha": version_metadata.get("version_sha"),
"build_number": version_metadata.get("build_number"),
"languages": languages,
"show_language_picker": len(languages) > 1,
"user_is_anonymous": user.is_anonymous,
"user_info_url": (
None if is_feature_enabled("MENU_HIDE_USER_INFO") else "/user_info/"
),
"user_logout_url": appbuilder.get_url_for_logout,
"user_login_url": appbuilder.get_url_for_login,
"locale": session.get("locale", "en"),
},
}
def _merge_theme_dicts(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
"""
Recursively merge overlay theme dict into base theme dict.
Arrays and non-dict values are replaced, not merged.
"""
result = base.copy()
for key, value in overlay.items():
if isinstance(result.get(key), dict) and isinstance(value, dict):
result[key] = _merge_theme_dicts(result[key], value)
else:
result[key] = value
return result
def _load_theme_from_model(
theme_model: ThemeModel | None,
fallback_theme: Theme | None,
theme_type: ThemeMode,
) -> Theme | None:
"""Load and parse theme from database model, merging with config theme as base."""
if theme_model:
try:
db_theme = json.loads(theme_model.json_data)
if fallback_theme:
merged = _merge_theme_dicts(dict(fallback_theme), db_theme)
return cast(Theme, merged)
return db_theme
except json.JSONDecodeError:
logger.error(
"Invalid JSON in system %s theme %s", theme_type.value, theme_model.id
)
return fallback_theme
return fallback_theme
def _process_theme(theme: Theme | None, theme_type: ThemeMode) -> Theme:
"""Process and validate a theme, returning an empty dict if invalid."""
if theme is None or theme == {}:
# When config theme is None or empty, don't provide a custom theme
# The frontend will use base theme only
return {}
elif not is_valid_theme(cast(dict[str, Any], theme)):
logger.warning(
"Invalid %s theme configuration: %s, clearing it",
theme_type.value,
theme,
)
return {}
return theme or {}
def get_theme_bootstrap_data() -> dict[str, Any]:
"""
Returns the theme data to be sent to the client.
"""
# Check if UI theme administration is enabled
enable_ui_admin = app.config.get("ENABLE_UI_THEME_ADMINISTRATION", False)
# Get config themes to use as fallback
config_theme_default = get_config_value("THEME_DEFAULT")
config_theme_dark = get_config_value("THEME_DARK")
if enable_ui_admin:
# Try to load themes from database
default_theme_model = ThemeDAO.find_system_default()
dark_theme_model = ThemeDAO.find_system_dark()
# Parse theme JSON from database models
default_theme = _load_theme_from_model(
default_theme_model, config_theme_default, ThemeMode.DEFAULT
)
dark_theme = _load_theme_from_model(
dark_theme_model, config_theme_dark, ThemeMode.DARK
)
else:
# UI theme administration disabled - use config-based themes
default_theme = config_theme_default
dark_theme = config_theme_dark
# Process and validate themes
default_theme = _process_theme(default_theme, ThemeMode.DEFAULT)
dark_theme = _process_theme(dark_theme, ThemeMode.DARK)
return {
"theme": {
"default": default_theme,
"dark": dark_theme,
"enableUiThemeAdministration": enable_ui_admin,
}
}
def get_default_spinner_svg() -> str | None:
"""
Load and cache the default spinner SVG content from frontend assets.
Returns:
str | None: SVG content as string, or None if file not found
"""
try:
# Path to frontend source SVG file (used by both frontend and backend)
svg_path = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"superset-frontend",
"packages",
"superset-ui-core",
"src",
"components",
"assets",
"images",
"loading.svg",
)
with open(svg_path, "r", encoding="utf-8") as f:
return f.read().strip()
except (FileNotFoundError, OSError, UnicodeDecodeError) as e:
logger.warning("Could not load default spinner SVG: %s", e)
return None
@cache_manager.cache.memoize(timeout=60)
def cached_common_bootstrap_data( # pylint: disable=unused-argument
user_id: int | None, locale: Locale | None
) -> dict[str, Any]:
"""Common data always sent to the client
The function is memoized as the return value only changes when user permissions
or configuration values change.
"""
# should not expose API TOKEN to frontend
frontend_config = {
k: (
list(app.config.get(k))
if isinstance(app.config.get(k), set)
else app.config.get(k)
)
for k in FRONTEND_CONF_KEYS
}
if app.config.get("SLACK_API_TOKEN"):
frontend_config["ALERT_REPORTS_NOTIFICATION_METHODS"] = [
ReportRecipientType.EMAIL,
ReportRecipientType.SLACK,
ReportRecipientType.SLACKV2,
]
else:
frontend_config["ALERT_REPORTS_NOTIFICATION_METHODS"] = [
ReportRecipientType.EMAIL,
]
# verify client has google sheets installed
available_specs = get_available_engine_specs()
frontend_config["HAS_GSHEETS_INSTALLED"] = (
GSheetsEngineSpec in available_specs
and bool(available_specs[GSheetsEngineSpec])
)
language = locale.language if locale else "en"
auth_type = app.config["AUTH_TYPE"]
auth_user_registration = app.config["AUTH_USER_REGISTRATION"]
frontend_config["AUTH_USER_REGISTRATION"] = auth_user_registration
should_show_recaptcha = auth_user_registration and (auth_type != AUTH_OAUTH)
if auth_user_registration:
frontend_config["AUTH_USER_REGISTRATION_ROLE"] = app.config[
"AUTH_USER_REGISTRATION_ROLE"
]
if should_show_recaptcha:
frontend_config["RECAPTCHA_PUBLIC_KEY"] = app.config["RECAPTCHA_PUBLIC_KEY"]
frontend_config["AUTH_TYPE"] = auth_type
if auth_type == AUTH_OAUTH:
oauth_providers = []
for provider in appbuilder.sm.oauth_providers:
oauth_providers.append(
{
"name": provider["name"],
"icon": provider["icon"],
}
)
frontend_config["AUTH_PROVIDERS"] = oauth_providers
bootstrap_data = {
"application_root": app.config["APPLICATION_ROOT"],
"static_assets_prefix": app.config["STATIC_ASSETS_PREFIX"],
"conf": frontend_config,
"locale": language,
"d3_format": app.config.get("D3_FORMAT"),
"d3_time_format": app.config.get("D3_TIME_FORMAT"),
"currencies": app.config.get("CURRENCIES"),
"deckgl_tiles": app.config.get("DECKGL_BASE_MAP"),
"feature_flags": get_feature_flags(),
"extra_sequential_color_schemes": app.config["EXTRA_SEQUENTIAL_COLOR_SCHEMES"],
"extra_categorical_color_schemes": app.config[
"EXTRA_CATEGORICAL_COLOR_SCHEMES"
],
"menu_data": menu_data(g.user),
"pdf_compression_level": app.config["PDF_COMPRESSION_LEVEL"],
}
bootstrap_data.update(app.config["COMMON_BOOTSTRAP_OVERRIDES_FUNC"](bootstrap_data))
bootstrap_data.update(get_theme_bootstrap_data())
return bootstrap_data
def common_bootstrap_payload() -> dict[str, Any]:
return cached_common_bootstrap_data(utils.get_user_id(), get_locale())
def get_spa_payload(extra_data: dict[str, Any] | None = None) -> dict[str, Any]:
"""Generate standardized payload for spa.html template rendering.
Centralizes the common payload structure used across all spa.html renders.
Args:
extra_data: Additional data to include in payload
Returns:
dict[str, Any]: Complete payload for spa.html template
"""
payload = {
"user": bootstrap_user_data(g.user, include_perms=True),
"common": common_bootstrap_payload(),
**(extra_data or {}),
}
return payload
def get_spa_template_context(
entry: str | None = "spa",
extra_bootstrap_data: dict[str, Any] | None = None,
**template_kwargs: Any,
) -> dict[str, Any]:
"""Generate standardized template context for spa.html rendering.
Centralizes spa.html template context to eliminate duplication while
preserving Flask-AppBuilder context requirements.
Args:
entry: Entry point name (spa, explore, embedded)
extra_bootstrap_data: Additional data for frontend bootstrap payload
**template_kwargs: Additional template variables
Returns:
dict[str, Any]: Template context for spa.html
"""
payload = get_spa_payload(extra_bootstrap_data)
# Extract theme data for template access
theme_data = get_theme_bootstrap_data().get("theme", {})
default_theme = theme_data.get("default", {})
theme_tokens = default_theme.get("token", {})
# Determine spinner content with precedence: theme SVG > theme URL > default SVG
spinner_svg = None
if theme_tokens.get("brandSpinnerSvg"):
# Use custom SVG from theme
spinner_svg = theme_tokens["brandSpinnerSvg"]
elif not theme_tokens.get("brandSpinnerUrl"):
# No custom URL either, use default SVG
spinner_svg = get_default_spinner_svg()
return {
"entry": entry,
"bootstrap_data": json.dumps(
payload, default=json.pessimistic_json_iso_dttm_ser
),
"theme_tokens": theme_tokens,
"spinner_svg": spinner_svg,
**template_kwargs,
}
class SupersetListWidget(ListWidget): # pylint: disable=too-few-public-methods
template = "superset/fab_overrides/list.html"
class SupersetModelView(ModelView):
page_size = 100
list_widget = SupersetListWidget
def render_app_template(self) -> FlaskResponse:
context = get_spa_template_context()
return self.render_template("superset/spa.html", **context)
class DeleteMixin: # pylint: disable=too-few-public-methods
def _delete(self: BaseView, primary_key: int) -> None:
"""
Delete function logic, override to implement different logic
deletes the record with primary_key = primary_key
:param primary_key:
record primary key to delete
"""
item = self.datamodel.get(primary_key, self._base_filters)
if not item:
abort(404)
try:
self.pre_delete(item)
except Exception as ex: # pylint: disable=broad-except
logger.error("Pre-delete error: %s", str(ex))
else:
view_menu = security_manager.find_view_menu(item.get_perm())
pvs = (
db.session.query(security_manager.permissionview_model)
.filter_by(view_menu=view_menu)
.all()
)
if self.datamodel.delete(item):
self.post_delete(item)
for pv in pvs:
db.session.delete(pv)
if view_menu:
db.session.delete(view_menu)
db.session.commit() # pylint: disable=consider-using-transaction
self.update_redirect()
@action(
"muldelete", __("Delete"), __("Delete all Really?"), "fa-trash", single=False
)
def muldelete(self: BaseView, items: list[Model]) -> FlaskResponse:
if not items:
abort(404)
for item in items:
try:
self.pre_delete(item)
except Exception as ex: # pylint: disable=broad-except
logger.error("Pre-delete error: %s", str(ex))
else:
self._delete(item.id)
self.update_redirect()
return redirect(self.get_redirect())
class DatasourceFilter(BaseFilter): # pylint: disable=too-few-public-methods
def apply(self, query: Query, value: Any) -> Query:
if security_manager.can_access_all_datasources():
return query
query = query.join(
models.Database,
models.Database.id == self.model.database_id,
)
return query.filter(get_dataset_access_filters(self.model))
class CsvResponse(Response):
"""
Override Response to take into account csv encoding from config.py
"""
charset = app.config["CSV_EXPORT"].get("encoding", "utf-8")
default_mimetype = "text/csv"
class XlsxResponse(Response):
"""
Override Response to use xlsx mimetype
"""
charset = "utf-8"
default_mimetype = (
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
def bind_field(
_: Any, form: DynamicForm, unbound_field: UnboundField, options: dict[Any, Any]
) -> Field:
"""
Customize how fields are bound by stripping all whitespace.
:param form: The form
:param unbound_field: The unbound field
:param options: The field options
:returns: The bound field
"""
filters = unbound_field.kwargs.get("filters", [])
filters.append(lambda x: x.strip() if isinstance(x, str) else x)
return unbound_field.bind(form=form, filters=filters, **options)
FlaskForm.Meta.bind_field = bind_field