933 lines
34 KiB
Python
Executable File
933 lines
34 KiB
Python
Executable File
# 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
|
|
from datetime import datetime
|
|
from typing import Any, Callable, cast
|
|
from urllib import parse
|
|
|
|
import simplejson as json
|
|
from flask import abort, flash, g, redirect, render_template, request, Response
|
|
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 superset import (
|
|
app,
|
|
appbuilder,
|
|
conf,
|
|
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.sql_lab import Query
|
|
from superset.models.user_attributes import UserAttribute
|
|
from superset.superset_typing import FlaskResponse
|
|
from superset.utils import core as utils
|
|
from superset.utils.cache import etag_cache
|
|
from superset.utils.core import (
|
|
base_json_conv,
|
|
DatasourceType,
|
|
get_user_id,
|
|
ReservedUrlParameters,
|
|
)
|
|
from superset.views.base import (
|
|
api,
|
|
BaseSupersetView,
|
|
common_bootstrap_payload,
|
|
CsvResponse,
|
|
data_payload_response,
|
|
deprecated,
|
|
generate_download_headers,
|
|
get_error_msg,
|
|
handle_api_exception,
|
|
json_error_response,
|
|
json_success,
|
|
)
|
|
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_with_flash,
|
|
sanitize_datasource_data,
|
|
)
|
|
from superset.viz import BaseViz
|
|
|
|
config = app.config
|
|
SQLLAB_QUERY_COST_ESTIMATE_TIMEOUT = config["SQLLAB_QUERY_COST_ESTIMATE_TIMEOUT"]
|
|
stats_logger = config["STATS_LOGGER"]
|
|
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/<int:slice_id>/")
|
|
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 = f"/explore/?form_data={form_data}"
|
|
|
|
if ReservedUrlParameters.is_standalone_mode():
|
|
endpoint += f"&{ReservedUrlParameters.STANDALONE}=true"
|
|
return redirect(endpoint)
|
|
|
|
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 = "No query."
|
|
|
|
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"),
|
|
"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/<cache_key>", 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
|
|
setattr(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/<datasource_type>/<int:datasource_id>/",
|
|
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/<cache_key>
|
|
# 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/<datasource_type>/<int:datasource_id>/",
|
|
methods=(
|
|
"GET",
|
|
"POST",
|
|
),
|
|
)
|
|
@expose(
|
|
"/explore/",
|
|
methods=(
|
|
"GET",
|
|
"POST",
|
|
),
|
|
)
|
|
@deprecated()
|
|
# pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
|
def explore(
|
|
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 = {}
|
|
|
|
form_data_key = request.args.get("form_data_key")
|
|
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:
|
|
flash(__("Error: %(msg)s", msg=ex.message), "danger")
|
|
return redirect("/chart/list/")
|
|
elif 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
|
|
if form_data_key:
|
|
flash(
|
|
_("Form data not found in cache, reverting to chart metadata.")
|
|
)
|
|
elif dataset_id:
|
|
initial_form_data["datasource"] = f"{dataset_id}__table"
|
|
if form_data_key:
|
|
flash(
|
|
_(
|
|
"Form data not found in cache, reverting to dataset metadata."
|
|
)
|
|
)
|
|
|
|
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 = []
|
|
|
|
if "selectedColumns" in form_data:
|
|
selectedColumns = form_data.pop("selectedColumns")
|
|
|
|
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: dict[str, Any] = {
|
|
"type": datasource_type,
|
|
"name": datasource_name,
|
|
"columns": [],
|
|
"metrics": [],
|
|
"database": {"id": 0, "backend": ""},
|
|
}
|
|
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
|
|
if isinstance(datasource, Query):
|
|
datasource_data["columns"] = datasource.columns
|
|
|
|
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_template(
|
|
"superset/basic.html",
|
|
bootstrap_data=json.dumps(
|
|
bootstrap_data, default=utils.pessimistic_json_iso_dttm_ser
|
|
),
|
|
entry="explore",
|
|
title=title,
|
|
standalone_mode=standalone_mode,
|
|
)
|
|
|
|
@staticmethod
|
|
def save_or_overwrite_slice(
|
|
# 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)
|
|
msg = _("Chart [{}] has been saved").format(slc.slice_name)
|
|
flash(msg, "success")
|
|
elif action == "overwrite" and slice_overwrite_perm:
|
|
ChartDAO.update(slc)
|
|
msg = _("Chart [{}] has been overwritten").format(slc.slice_name)
|
|
flash(msg, "success")
|
|
|
|
# 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,
|
|
)
|
|
|
|
flash(
|
|
_("Chart [{}] was added to dashboard [{}]").format(
|
|
slc.slice_name, dash.dashboard_title
|
|
),
|
|
"success",
|
|
)
|
|
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 [],
|
|
)
|
|
flash(
|
|
_(
|
|
"Dashboard [{}] just got created and chart [{}] was added to it"
|
|
).format(dash.dashboard_title, slc.slice_name),
|
|
"success",
|
|
)
|
|
|
|
if dash and slc not in dash.slices:
|
|
dash.slices.append(slc)
|
|
db.session.commit()
|
|
|
|
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=base_json_conv,
|
|
),
|
|
)
|
|
|
|
@has_access
|
|
@expose("/dashboard/<dashboard_id_or_slug>/")
|
|
@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:
|
|
abort(404)
|
|
|
|
try:
|
|
dashboard.raise_for_access()
|
|
except SupersetSecurityException as ex:
|
|
return redirect_with_flash(
|
|
url="/dashboard/list/",
|
|
message=utils.error_msg_from_exception(ex),
|
|
category="danger",
|
|
)
|
|
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"
|
|
),
|
|
)
|
|
|
|
return self.render_template(
|
|
"superset/spa.html",
|
|
entry="spa",
|
|
title=dashboard.dashboard_title, # dashboard title is always visible
|
|
bootstrap_data=json.dumps(
|
|
{
|
|
"user": bootstrap_user_data(g.user, include_perms=True),
|
|
"common": common_bootstrap_payload(),
|
|
},
|
|
default=utils.pessimistic_json_iso_dttm_ser,
|
|
),
|
|
standalone_mode=ReservedUrlParameters.is_standalone_mode(),
|
|
)
|
|
|
|
@has_access
|
|
@expose("/dashboard/p/<key>/", methods=("GET",))
|
|
def dashboard_permalink(
|
|
self,
|
|
key: str,
|
|
) -> FlaskResponse:
|
|
try:
|
|
value = GetDashboardPermalinkCommand(key).run()
|
|
except DashboardPermalinkGetFailedError as ex:
|
|
flash(__("Error: %(msg)s", msg=ex.message), "danger")
|
|
return redirect("/dashboard/list/")
|
|
except DashboardAccessDeniedError as ex:
|
|
flash(__("Error: %(msg)s", msg=ex.message), "danger")
|
|
return redirect("/dashboard/list/")
|
|
if not value:
|
|
return json_error_response(_("permalink state not found"), status=404)
|
|
dashboard_id, state = value["dashboardId"], value.get("state", {})
|
|
url = f"/superset/dashboard/{dashboard_id}?permalink_key={key}"
|
|
if url_params := state.get("urlParams"):
|
|
params = parse.urlencode(url_params)
|
|
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)
|
|
|
|
@expose("/theme/")
|
|
def theme(self) -> FlaskResponse:
|
|
return self.render_template("superset/theme.html")
|
|
|
|
@api
|
|
@handle_api_exception
|
|
@has_access
|
|
@event_logger.log_this
|
|
@expose("/fetch_datasource_metadata")
|
|
@deprecated(
|
|
new_target="api/v1/database/<int:pk>/table/<path:table_name>/<schema_name>/"
|
|
)
|
|
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)))
|
|
|
|
@app.errorhandler(500)
|
|
def show_traceback(self) -> FlaskResponse:
|
|
return (
|
|
render_template("superset/traceback.html", error_msg=get_error_msg()),
|
|
500,
|
|
)
|
|
|
|
@event_logger.log_this
|
|
@expose("/welcome/")
|
|
def welcome(self) -> FlaskResponse:
|
|
"""Personalized welcome page"""
|
|
if not g.user or not get_user_id():
|
|
if conf["PUBLIC_ROLE_LIKE"]:
|
|
return self.render_template("superset/public_welcome.html")
|
|
return redirect(appbuilder.get_url_for_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_template(
|
|
"superset/spa.html",
|
|
entry="spa",
|
|
bootstrap_data=json.dumps(
|
|
payload, default=utils.pessimistic_json_iso_dttm_ser
|
|
),
|
|
)
|
|
|
|
@has_access
|
|
@event_logger.log_this
|
|
@expose("/sqllab/history/", methods=("GET",))
|
|
@deprecated(new_target="/sqllab/history")
|
|
def sqllab_history(self) -> FlaskResponse:
|
|
return redirect("/sqllab/history")
|