# 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. # pylint: disable=invalid-name from __future__ import annotations import contextlib import logging import os import re from datetime import datetime from typing import Any, Callable, cast from urllib import parse from flask import ( abort, current_app as app, g, redirect, request, Response, send_file, url_for, ) from flask_appbuilder import expose from flask_appbuilder.security.decorators import ( has_access, has_access_api, permission_name, ) from flask_babel import gettext as __, lazy_gettext as _ from sqlalchemy.exc import SQLAlchemyError from werkzeug.utils import safe_join from superset import ( db, event_logger, is_feature_enabled, security_manager, ) from superset.async_events.async_query_manager import AsyncQueryTokenException from superset.commands.chart.exceptions import ChartNotFoundError from superset.commands.chart.warm_up_cache import ChartWarmUpCacheCommand from superset.commands.dashboard.exceptions import DashboardAccessDeniedError from superset.commands.dashboard.permalink.get import GetDashboardPermalinkCommand from superset.commands.dataset.exceptions import DatasetNotFoundError from superset.commands.explore.form_data.create import CreateFormDataCommand from superset.commands.explore.form_data.get import GetFormDataCommand from superset.commands.explore.form_data.parameters import CommandParameters from superset.commands.explore.permalink.get import GetExplorePermalinkCommand from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType from superset.connectors.sqla.models import BaseDatasource, SqlaTable from superset.daos.chart import ChartDAO from superset.daos.datasource import DatasourceDAO from superset.dashboards.permalink.exceptions import DashboardPermalinkGetFailedError from superset.exceptions import ( CacheLoadError, SupersetException, SupersetSecurityException, ) from superset.explore.permalink.exceptions import ExplorePermalinkGetFailedError from superset.extensions import async_query_manager, cache_manager from superset.models.core import Database from superset.models.dashboard import Dashboard from superset.models.slice import Slice from superset.models.user_attributes import UserAttribute from superset.superset_typing import ( ExplorableData, FlaskResponse, ) from superset.tasks.utils import get_current_user from superset.utils import core as utils, json from superset.utils.cache import etag_cache from superset.utils.core import ( DatasourceType, get_user_id, ReservedUrlParameters, ) from superset.views.base import ( api, BaseSupersetView, common_bootstrap_payload, CsvResponse, data_payload_response, deprecated, generate_download_headers, json_error_response, json_success, ) from superset.views.error_handling import handle_api_exception from superset.views.utils import ( bootstrap_user_data, check_datasource_perms, check_explore_cache_perms, check_resource_permissions, get_datasource_info, get_form_data, get_viz, loads_request_json, redirect_to_login, sanitize_datasource_data, ) from superset.viz import BaseViz logger = logging.getLogger(__name__) DATASOURCE_MISSING_ERR = __("The data source seems to have been deleted") USER_MISSING_ERR = __("The user seems to have been deleted") PARAMETER_MISSING_ERR = __( "Please check your template parameters for syntax errors and make sure " "they match across your SQL query and Set Parameters. Then, try running " "your query again." ) SqlResults = dict[str, Any] class Superset(BaseSupersetView): """The base views for Superset!""" logger = logging.getLogger(__name__) @has_access @event_logger.log_this @expose("/slice//") def slice(self, slice_id: int) -> FlaskResponse: _, slc = get_form_data(slice_id, use_slice_data=True) if not slc: abort(404) form_data = parse.quote(json.dumps({"slice_id": slice_id})) endpoint_params = {"form_data": f"{form_data}"} if ReservedUrlParameters.is_standalone_mode(): endpoint_params[ReservedUrlParameters.STANDALONE.value] = "true" return redirect(url_for("ExploreView.root", **endpoint_params)) def get_query_string_response(self, viz_obj: BaseViz) -> FlaskResponse: query = None try: if query_obj := viz_obj.query_obj(): query = viz_obj.datasource.get_query_str(query_obj) except Exception as ex: # pylint: disable=broad-except err_msg = utils.error_msg_from_exception(ex) logger.exception(err_msg) return json_error_response(err_msg) if not query: query = __("Query cannot be loaded.") return self.json_response( {"query": query, "language": viz_obj.datasource.query_language} ) def get_raw_results(self, viz_obj: BaseViz) -> FlaskResponse: payload = viz_obj.get_df_payload() if viz_obj.has_error(payload): return json_error_response(payload=payload, status=400) return self.json_response( { "data": payload["df"].to_dict("records") if payload["df"] is not None else [], "colnames": payload.get("colnames"), "coltypes": payload.get("coltypes"), "rowcount": payload.get("rowcount"), "sql_rowcount": payload.get("sql_rowcount"), }, ) def get_samples(self, viz_obj: BaseViz) -> FlaskResponse: return self.json_response(viz_obj.get_samples()) @staticmethod def send_data_payload_response(viz_obj: BaseViz, payload: Any) -> FlaskResponse: return data_payload_response(*viz_obj.payload_json_and_has_error(payload)) def generate_json( self, viz_obj: BaseViz, response_type: str | None = None ) -> FlaskResponse: if response_type == ChartDataResultFormat.CSV: return CsvResponse( viz_obj.get_csv(), headers=generate_download_headers("csv") ) if response_type == ChartDataResultType.QUERY: return self.get_query_string_response(viz_obj) if response_type == ChartDataResultType.RESULTS: return self.get_raw_results(viz_obj) if response_type == ChartDataResultType.SAMPLES: return self.get_samples(viz_obj) payload = viz_obj.get_payload() return self.send_data_payload_response(viz_obj, payload) @event_logger.log_this @api @has_access_api @handle_api_exception @permission_name("explore_json") @expose("/explore_json/data/", methods=("GET",)) @check_resource_permissions(check_explore_cache_perms) @deprecated(eol_version="5.0.0") def explore_json_data(self, cache_key: str) -> FlaskResponse: """Serves cached result data for async explore_json calls `self.generate_json` receives this input and returns different payloads based on the request args in the first block TODO: form_data should not be loaded twice from cache (also loaded in `check_explore_cache_perms`) """ try: cached = cache_manager.cache.get(cache_key) if not cached: raise CacheLoadError("Cached data not found") form_data = cached.get("form_data") response_type = cached.get("response_type") # Set form_data in Flask Global as it is used as a fallback # for async queries with jinja context g.form_data = form_data datasource_id, datasource_type = get_datasource_info(None, None, form_data) viz_obj = get_viz( datasource_type=cast(str, datasource_type), datasource_id=datasource_id, form_data=form_data, force_cached=True, ) return self.generate_json(viz_obj, response_type) except SupersetException as ex: return json_error_response(utils.error_msg_from_exception(ex), 400) @api @has_access_api @handle_api_exception @event_logger.log_this @expose( "/explore_json///", methods=( "GET", "POST", ), ) @expose( "/explore_json/", methods=( "GET", "POST", ), ) @etag_cache() @check_resource_permissions(check_datasource_perms) @deprecated(eol_version="5.0.0") def explore_json( self, datasource_type: str | None = None, datasource_id: int | None = None ) -> FlaskResponse: """Serves all request that GET or POST form_data This endpoint evolved to be the entry point of many different requests that GETs or POSTs a form_data. `self.generate_json` receives this input and returns different payloads based on the request args in the first block TODO: break into one endpoint for each return shape""" response_type = ChartDataResultFormat.JSON.value responses: list[ChartDataResultFormat | ChartDataResultType] = list( ChartDataResultFormat ) responses.extend(list(ChartDataResultType)) for response_option in responses: if request.args.get(response_option) == "true": response_type = response_option break # Verify user has permission to export CSV file if ( response_type == ChartDataResultFormat.CSV and not security_manager.can_access("can_csv", "Superset") ): return json_error_response( _("You don't have the rights to download as csv"), status=403, ) form_data = get_form_data()[0] try: datasource_id, datasource_type = get_datasource_info( datasource_id, datasource_type, form_data ) force = request.args.get("force") == "true" # TODO: support CSV, SQL query and other non-JSON types if ( is_feature_enabled("GLOBAL_ASYNC_QUERIES") and response_type == ChartDataResultFormat.JSON ): # First, look for the chart query results in the cache. with contextlib.suppress(CacheLoadError): viz_obj = get_viz( datasource_type=cast(str, datasource_type), datasource_id=datasource_id, form_data=form_data, force_cached=True, force=force, ) payload = viz_obj.get_payload() # If the chart query has already been cached, return it immediately. if payload is not None: return self.send_data_payload_response(viz_obj, payload) # Otherwise, kick off a background job to run the chart query. # Clients will either poll or be notified of query completion, # at which point they will call the /explore_json/data/ # endpoint to retrieve the results. try: async_channel_id = ( async_query_manager.parse_channel_id_from_request(request) ) job_metadata = async_query_manager.submit_explore_json_job( async_channel_id, form_data, response_type, force, get_user_id() ) except AsyncQueryTokenException: return json_error_response("Not authorized", 401) return json_success(json.dumps(job_metadata), status=202) viz_obj = get_viz( datasource_type=cast(str, datasource_type), datasource_id=datasource_id, form_data=form_data, force=force, ) return self.generate_json(viz_obj, response_type) except SupersetException as ex: return json_error_response(utils.error_msg_from_exception(ex), 400) @staticmethod def get_redirect_url() -> str: """Assembles the redirect URL to the new endpoint. It also replaces the form_data param with a form_data_key by saving the original content to the cache layer. """ redirect_url = request.url.replace("/superset/explore", "/explore") form_data_key = None if request_form_data := request.args.get("form_data"): parsed_form_data = loads_request_json(request_form_data) slice_id = parsed_form_data.get( "slice_id", int(request.args.get("slice_id", 0)) ) if datasource := parsed_form_data.get("datasource"): datasource_id, datasource_type = datasource.split("__") parameters = CommandParameters( datasource_id=datasource_id, datasource_type=datasource_type, chart_id=slice_id, form_data=request_form_data, ) form_data_key = CreateFormDataCommand(parameters).run() if form_data_key: url = parse.urlparse(redirect_url) query = parse.parse_qs(url.query) query.pop("form_data") query["form_data_key"] = [form_data_key] url = url._replace(query=parse.urlencode(query, True)) redirect_url = parse.urlunparse(url) # Return a relative URL url = parse.urlparse(redirect_url) return f"{url.path}?{url.query}" if url.query else url.path @has_access @event_logger.log_this @expose( "/explore///", methods=( "GET", "POST", ), ) @expose( "/explore/", methods=( "GET", "POST", ), ) @deprecated() # pylint: disable=too-many-locals,too-many-branches,too-many-statements def explore( # noqa: C901 self, datasource_type: str | None = None, datasource_id: int | None = None, key: str | None = None, ) -> FlaskResponse: if request.method == "GET": return redirect(Superset.get_redirect_url()) initial_form_data = {} if key is not None: command = GetExplorePermalinkCommand(key) try: if permalink_value := command.run(): state = permalink_value["state"] initial_form_data = state["formData"] url_params = state.get("urlParams") if url_params: initial_form_data["url_params"] = dict(url_params) else: return json_error_response( _("Error: permalink state not found"), status=404 ) except (ChartNotFoundError, ExplorePermalinkGetFailedError) as ex: return json_error_response( __("Error: %(msg)s", msg=ex.message), status=404 ) elif form_data_key := request.args.get("form_data_key"): parameters = CommandParameters(key=form_data_key) value = GetFormDataCommand(parameters).run() initial_form_data = json.loads(value) if value else {} if not initial_form_data: slice_id = request.args.get("slice_id") dataset_id = request.args.get("dataset_id") if slice_id: initial_form_data["slice_id"] = slice_id elif dataset_id: initial_form_data["datasource"] = f"{dataset_id}__table" form_data, slc = get_form_data( use_slice_data=True, initial_form_data=initial_form_data ) query_context = request.form.get("query_context") try: datasource_id, datasource_type = get_datasource_info( datasource_id, datasource_type, form_data ) except SupersetException: datasource_id = None # fallback unknown datasource to table type datasource_type = SqlaTable.type datasource: BaseDatasource | None = None if datasource_id is not None: with contextlib.suppress(DatasetNotFoundError): datasource = DatasourceDAO.get_datasource( DatasourceType("table"), datasource_id, ) datasource_name = datasource.name if datasource else _("[Missing Dataset]") viz_type = form_data.get("viz_type") if not viz_type and datasource and datasource.default_endpoint: return redirect(datasource.default_endpoint) selectedColumns = [] # noqa: N806 if "selectedColumns" in form_data: selectedColumns = form_data.pop("selectedColumns") # noqa: N806 if "viz_type" not in form_data: form_data["viz_type"] = app.config["DEFAULT_VIZ_TYPE"] if app.config["DEFAULT_VIZ_TYPE"] == "table": all_columns = [] for x in selectedColumns: all_columns.append(x["name"]) form_data["all_columns"] = all_columns # slc perms slice_add_perm = security_manager.can_access("can_write", "Chart") slice_overwrite_perm = security_manager.is_owner(slc) if slc else False slice_download_perm = security_manager.can_access("can_csv", "Superset") form_data["datasource"] = str(datasource_id) + "__" + cast(str, datasource_type) # On explore, merge legacy and extra filters into the form data utils.convert_legacy_filters_into_adhoc(form_data) utils.merge_extra_filters(form_data) # merge request url params if request.method == "GET": utils.merge_request_params(form_data, request.args) # handle save or overwrite action = request.args.get("action") if action == "overwrite" and not slice_overwrite_perm: return json_error_response( _("You don't have the rights to alter this chart"), status=403, ) if action == "saveas" and not slice_add_perm: return json_error_response( _("You don't have the rights to create a chart"), status=403, ) if action in ("saveas", "overwrite") and datasource: return self.save_or_overwrite_slice( slc, slice_add_perm, slice_overwrite_perm, slice_download_perm, datasource.id, datasource.type, datasource.name, query_context, ) standalone_mode = ReservedUrlParameters.is_standalone_mode() force = request.args.get("force") in {"force", "1", "true"} dummy_datasource_data: ExplorableData = { "type": datasource_type or "unknown", "name": datasource_name, "columns": [], "metrics": [], "database": {"id": 0, "backend": ""}, } datasource_data: ExplorableData try: datasource_data = datasource.data if datasource else dummy_datasource_data except (SupersetException, SQLAlchemyError): datasource_data = dummy_datasource_data if datasource: datasource_data["owners"] = datasource.owners_data bootstrap_data = { "can_add": slice_add_perm, "datasource": sanitize_datasource_data(datasource_data), "form_data": form_data, "datasource_id": datasource_id, "datasource_type": datasource_type, "slice": slc.data if slc else None, "standalone": standalone_mode, "force": force, "user": bootstrap_user_data(g.user, include_perms=True), "forced_height": request.args.get("height"), "common": common_bootstrap_payload(), } if slc: title = slc.slice_name elif datasource: table_name = ( datasource.table_name if datasource_type == "table" else datasource.datasource_name ) title = _("Explore - %(table)s", table=table_name) else: title = _("Explore") return self.render_app_template( extra_bootstrap_data=bootstrap_data, entry="explore", title=title, standalone_mode=standalone_mode, ) @staticmethod def save_or_overwrite_slice( # noqa: C901 # pylint: disable=too-many-arguments,too-many-locals slc: Slice | None, slice_add_perm: bool, slice_overwrite_perm: bool, slice_download_perm: bool, datasource_id: int, datasource_type: str, datasource_name: str, query_context: str | None = None, ) -> FlaskResponse: """Save or overwrite a slice""" slice_name = request.args.get("slice_name") action = request.args.get("action") form_data = get_form_data()[0] if action == "saveas": if "slice_id" in form_data: form_data.pop("slice_id") # don't save old slice_id slc = Slice(owners=[g.user] if g.user else []) utils.remove_extra_adhoc_filters(form_data) assert slc slc.params = json.dumps(form_data, indent=2, sort_keys=True) slc.datasource_name = datasource_name slc.viz_type = form_data["viz_type"] slc.datasource_type = datasource_type slc.datasource_id = datasource_id slc.last_saved_by = g.user slc.last_saved_at = datetime.now() slc.slice_name = slice_name slc.query_context = query_context if action == "saveas" and slice_add_perm: ChartDAO.create(slc) db.session.commit() # pylint: disable=consider-using-transaction elif action == "overwrite" and slice_overwrite_perm: ChartDAO.update(slc) db.session.commit() # pylint: disable=consider-using-transaction # Adding slice to a dashboard if requested dash: Dashboard | None = None save_to_dashboard_id = request.args.get("save_to_dashboard_id") new_dashboard_name = request.args.get("new_dashboard_name") if save_to_dashboard_id: # Adding the chart to an existing dashboard dash = cast( Dashboard, db.session.query(Dashboard) .filter_by(id=int(save_to_dashboard_id)) .one(), ) # check edit dashboard permissions dash_overwrite_perm = security_manager.is_owner(dash) if not dash_overwrite_perm: return json_error_response( _("You don't have the rights to alter this dashboard"), status=403, ) elif new_dashboard_name: # Creating and adding to a new dashboard # check create dashboard permissions dash_add_perm = security_manager.can_access("can_write", "Dashboard") if not dash_add_perm: return json_error_response( _("You don't have the rights to create a dashboard"), status=403, ) dash = Dashboard( dashboard_title=request.args.get("new_dashboard_name"), owners=[g.user] if g.user else [], ) if dash and slc not in dash.slices: dash.slices.append(slc) db.session.commit() # pylint: disable=consider-using-transaction response = { "can_add": slice_add_perm, "can_download": slice_download_perm, "form_data": slc.form_data, "slice": slc.data, "dashboard_url": dash.url if dash else None, "dashboard_id": dash.id if dash else None, } if dash and request.args.get("goto_dash") == "true": response.update({"dashboard": dash.url}) return json_success(json.dumps(response)) @event_logger.log_this @api @has_access_api @expose("/warm_up_cache/", methods=("GET",)) @deprecated(new_target="api/v1/chart/warm_up_cache/") def warm_up_cache(self) -> FlaskResponse: """Warms up the cache for the slice or table. Note for slices a force refresh occurs. In terms of the `extra_filters` these can be obtained from records in the JSON encoded `logs.json` column associated with the `explore_json` action. """ slice_id = request.args.get("slice_id") dashboard_id = request.args.get("dashboard_id") table_name = request.args.get("table_name") db_name = request.args.get("db_name") extra_filters = request.args.get("extra_filters") slices: list[Slice] = [] if not slice_id and not (table_name and db_name): return json_error_response( __( "Malformed request. slice_id or table_name and db_name " "arguments are expected" ), status=400, ) if slice_id: slices = db.session.query(Slice).filter_by(id=slice_id).all() if not slices: return json_error_response( __("Chart %(id)s not found", id=slice_id), status=404 ) elif table_name and db_name: table = ( db.session.query(SqlaTable) .join(Database) .filter( Database.database_name == db_name or SqlaTable.table_name == table_name ) ).one_or_none() if not table: return json_error_response( __( "Table %(table)s wasn't found in the database %(db)s", table=table_name, db=db_name, ), status=404, ) slices = ( db.session.query(Slice) .filter_by(datasource_id=table.id, datasource_type=table.type) .all() ) return json_success( json.dumps( [ { "slice_id" if key == "chart_id" else key: value for key, value in ChartWarmUpCacheCommand( slc, dashboard_id, extra_filters ) .run() .items() } for slc in slices ], default=json.base_json_conv, ), ) @has_access @expose("/dashboard//") @event_logger.log_this_with_extra_payload def dashboard( self, dashboard_id_or_slug: str, add_extra_log_payload: Callable[..., None] = lambda **kwargs: None, ) -> FlaskResponse: """ Server side rendering for a dashboard. :param dashboard_id_or_slug: identifier for dashboard :param add_extra_log_payload: added by `log_this_with_manual_updates`, set a default value to appease pylint """ dashboard = Dashboard.get(dashboard_id_or_slug) if not dashboard: if not get_current_user(): return redirect_to_login() abort(404) # Redirect anonymous users to login for unpublished dashboards, # in the edge case where a dataset has been shared with public if not get_current_user() and not dashboard.published: return redirect_to_login() try: dashboard.raise_for_access() except SupersetSecurityException: if not get_current_user(): return redirect_to_login() abort(404) add_extra_log_payload( dashboard_id=dashboard.id, dashboard_version="v2", dash_edit_perm=( security_manager.is_owner(dashboard) and security_manager.can_access("can_write", "Dashboard") ), edit_mode=( request.args.get(ReservedUrlParameters.EDIT_MODE.value) == "true" ), ) bootstrap_payload = { "user": bootstrap_user_data(g.user, include_perms=True), "common": common_bootstrap_payload(), } return self.render_app_template( extra_bootstrap_data=bootstrap_payload, title=dashboard.dashboard_title, # dashboard title is always visible standalone_mode=ReservedUrlParameters.is_standalone_mode(), ) @has_access @expose("/dashboard/p//", methods=("GET",)) def dashboard_permalink( self, key: str, ) -> FlaskResponse: try: value = GetDashboardPermalinkCommand(key).run() except (DashboardPermalinkGetFailedError, DashboardAccessDeniedError) as ex: return json_error_response(__("Error: %(msg)s", msg=ex.message), status=404) if not value: return json_error_response(_("permalink state not found"), status=404) dashboard_id, state = value["dashboardId"], value.get("state", {}) url = url_for( "Superset.dashboard", dashboard_id_or_slug=dashboard_id, permalink_key=key ) if url_params := state.get("urlParams"): for param_key, param_val in url_params: if param_key == "native_filters": # native_filters doesnt need to be encoded here url = f"{url}&native_filters={param_val}" else: params = parse.urlencode([(param_key, param_val)]) url = f"{url}&{params}" if original_params := request.query_string.decode(): url = f"{url}&{original_params}" if hash_ := state.get("anchor", state.get("hash")): url = f"{url}#{hash_}" return redirect(url) @api @has_access @event_logger.log_this @expose("/log/", methods=("POST",)) def log(self) -> FlaskResponse: return Response(status=200) @api @handle_api_exception @has_access @event_logger.log_this @expose("/fetch_datasource_metadata") @deprecated( new_target="api/v1/database//table///" ) def fetch_datasource_metadata(self) -> FlaskResponse: """ Fetch the datasource metadata. :returns: The Flask response :raises SupersetSecurityException: If the user cannot access the resource """ datasource_id, datasource_type = request.args["datasourceKey"].split("__") datasource = DatasourceDAO.get_datasource( DatasourceType(datasource_type), int(datasource_id) ) # Check if datasource exists if not datasource: return json_error_response(DATASOURCE_MISSING_ERR) datasource.raise_for_access() return json_success(json.dumps(sanitize_datasource_data(datasource.data))) @event_logger.log_this @has_access @expose("/language_pack//") def language_pack(self, lang: str) -> FlaskResponse: # Only allow expected language formats like "en", "pt_BR", etc. if not re.match(r"^[a-z]{2,3}(_[A-Z]{2})?$", lang): abort(400, "Invalid language code") base_dir = os.path.join(os.path.dirname(__file__), "..", "translations") file_path = safe_join(base_dir, lang, "LC_MESSAGES", "messages.json") if file_path and os.path.isfile(file_path): return send_file(file_path, mimetype="application/json") return json_error_response( "Language pack doesn't exist on the server", status=404 ) @event_logger.log_this @expose("/welcome/") def welcome(self) -> FlaskResponse: """Personalized welcome page""" if not g.user or not get_user_id(): return redirect_to_login() if welcome_dashboard_id := ( db.session.query(UserAttribute.welcome_dashboard_id) .filter_by(user_id=get_user_id()) .scalar() ): return self.dashboard(dashboard_id_or_slug=str(welcome_dashboard_id)) payload = { "user": bootstrap_user_data(g.user, include_perms=True), "common": common_bootstrap_payload(), } return self.render_app_template(extra_bootstrap_data=payload) @has_access @event_logger.log_this @expose("/sqllab/history/", methods=("GET",)) @deprecated(new_target="/sqllab/history") def sqllab_history(self) -> FlaskResponse: return redirect(url_for("SqllabView.history"))