mirror of
https://github.com/apache/superset.git
synced 2026-04-09 19:35:21 +00:00
709 lines
24 KiB
Python
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
|