# 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=too-many-lines import functools import logging from datetime import datetime from io import BytesIO from typing import Any, Callable, cast from zipfile import is_zipfile, ZipFile from flask import current_app, g, redirect, request, Response, send_file, url_for from flask_appbuilder import permission_name from flask_appbuilder.api import expose, merge_response_func, protect, rison, safe from flask_appbuilder.const import ( API_DESCRIPTION_COLUMNS_RIS_KEY, API_LABEL_COLUMNS_RIS_KEY, API_LIST_COLUMNS_RIS_KEY, API_LIST_TITLE_RIS_KEY, API_ORDER_COLUMNS_RIS_KEY, ) from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import gettext, ngettext from marshmallow import ValidationError from werkzeug.wrappers import Response as WerkzeugResponse from werkzeug.wsgi import FileWrapper from superset import db from superset.charts.schemas import ChartEntityResponseSchema from superset.commands.dashboard.copy import CopyDashboardCommand from superset.commands.dashboard.create import CreateDashboardCommand from superset.commands.dashboard.delete import ( DeleteDashboardCommand, DeleteEmbeddedDashboardCommand, ) from superset.commands.dashboard.exceptions import ( DashboardAccessDeniedError, DashboardColorsConfigUpdateFailedError, DashboardCopyError, DashboardCreateFailedError, DashboardDeleteFailedError, DashboardForbiddenError, DashboardInvalidError, DashboardNativeFiltersUpdateFailedError, DashboardNotFoundError, DashboardUpdateFailedError, ) from superset.commands.dashboard.export import ExportDashboardsCommand from superset.commands.dashboard.fave import AddFavoriteDashboardCommand from superset.commands.dashboard.importers.dispatcher import ImportDashboardsCommand from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand from superset.commands.dashboard.unfave import DelFavoriteDashboardCommand from superset.commands.dashboard.update import ( UpdateDashboardColorsConfigCommand, UpdateDashboardCommand, UpdateDashboardNativeFiltersCommand, ) from superset.commands.database.exceptions import DatasetValidationError from superset.commands.exceptions import TagForbiddenError from superset.commands.importers.exceptions import NoValidFilesFoundError from superset.commands.importers.v1.utils import get_contents_from_bundle from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.daos.dashboard import DashboardDAO, EmbeddedDashboardDAO from superset.dashboards.filters import ( DashboardAccessFilter, DashboardCertifiedFilter, DashboardCreatedByMeFilter, DashboardFavoriteFilter, DashboardHasCreatedByFilter, DashboardTagIdFilter, DashboardTagNameFilter, DashboardTitleOrSlugFilter, FilterRelatedRoles, ) from superset.dashboards.permalink.types import DashboardPermalinkState from superset.dashboards.schemas import ( CacheScreenshotSchema, DashboardCacheScreenshotResponseSchema, DashboardColorsConfigUpdateSchema, DashboardCopySchema, DashboardDatasetSchema, DashboardGetResponseSchema, DashboardNativeFiltersConfigUpdateSchema, DashboardPostSchema, DashboardPutSchema, DashboardScreenshotPostSchema, EmbeddedDashboardConfigSchema, EmbeddedDashboardResponseSchema, get_delete_ids_schema, get_export_ids_schema, get_fav_star_ids_schema, GetFavStarIdsSchema, openapi_spec_methods_override, screenshot_query_schema, TabsPayloadSchema, thumbnail_query_schema, ) from superset.exceptions import ScreenshotImageNotAvailableException from superset.extensions import event_logger from superset.models.dashboard import Dashboard from superset.models.embedded_dashboard import EmbeddedDashboard from superset.security.guest_token import GuestUser from superset.tasks.thumbnails import ( cache_dashboard_screenshot, cache_dashboard_thumbnail, ) from superset.tasks.utils import get_current_user from superset.utils import json from superset.utils.core import parse_boolean_string from superset.utils.file import get_filename from superset.utils.pdf import build_pdf_from_screenshots from superset.utils.screenshots import ( DashboardScreenshot, DEFAULT_DASHBOARD_WINDOW_SIZE, ScreenshotCachePayload, ) from superset.utils.urls import get_url_path from superset.views.base_api import ( BaseSupersetModelRestApi, RelatedFieldFilter, requires_form_data, requires_json, statsd_metrics, validate_feature_flags, ) from superset.views.custom_tags_api_mixin import CustomTagsOptimizationMixin from superset.views.error_handling import handle_api_exception from superset.views.filters import ( BaseFilterRelatedRoles, BaseFilterRelatedUsers, FilterRelatedOwners, ) logger = logging.getLogger(__name__) def with_dashboard( f: Callable[[BaseSupersetModelRestApi, Dashboard], Response], ) -> Callable[[BaseSupersetModelRestApi, str], Response]: """ A decorator that looks up the dashboard by id or slug and passes it to the api. Route must include an parameter. Responds with 403 or 404 without calling the route, if necessary. """ def wraps(self: BaseSupersetModelRestApi, id_or_slug: str) -> Response: try: dash = DashboardDAO.get_by_id_or_slug(id_or_slug) return f(self, dash) except DashboardAccessDeniedError: return self.response_403() except DashboardNotFoundError: return self.response_404() return functools.update_wrapper(wraps, f) # Base columns (everything except tags) BASE_LIST_COLUMNS = [ "id", "uuid", "published", "status", "slug", "url", "thumbnail_url", "certified_by", "certification_details", "changed_by.first_name", "changed_by.last_name", "changed_by.id", "changed_by_name", "changed_on_utc", "changed_on_delta_humanized", "created_on_delta_humanized", "created_by.first_name", "created_by.id", "created_by.last_name", "dashboard_title", "owners.id", "owners.first_name", "owners.last_name", "roles.id", "roles.name", "is_managed_externally", "uuid", ] # Full tags (current behavior - includes all tag types) FULL_TAG_LIST_COLUMNS = BASE_LIST_COLUMNS + [ "tags.id", "tags.name", "tags.type", ] # Custom tags only CUSTOM_TAG_LIST_COLUMNS = BASE_LIST_COLUMNS + [ "custom_tags.id", "custom_tags.name", "custom_tags.type", ] # pylint: disable=too-many-public-methods class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi): datamodel = SQLAInterface(Dashboard) include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.IMPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "favorite_status", "add_favorite", "remove_favorite", "get_charts", "get_datasets", "get_tabs", "get_embedded", "set_embedded", "delete_embedded", "thumbnail", "copy_dash", "cache_dashboard_screenshot", "screenshot", "put_filters", "put_colors", } resource_name = "dashboard" allow_browser_login = True class_permission_name = "Dashboard" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP # Default list_columns (used if config not set) list_columns = FULL_TAG_LIST_COLUMNS def __init__(self) -> None: # Configure custom tags optimization (mixin handles the logic) self._setup_custom_tags_optimization( config_key="DASHBOARD_LIST_CUSTOM_TAGS_ONLY", full_columns=FULL_TAG_LIST_COLUMNS, custom_columns=CUSTOM_TAG_LIST_COLUMNS, ) super().__init__() @expose("/", methods=("GET",)) @protect() @safe @permission_name("get") @merge_response_func( BaseSupersetModelRestApi.merge_order_columns, API_ORDER_COLUMNS_RIS_KEY ) @merge_response_func( BaseSupersetModelRestApi.merge_list_label_columns, API_LABEL_COLUMNS_RIS_KEY ) @merge_response_func( BaseSupersetModelRestApi.merge_description_columns, API_DESCRIPTION_COLUMNS_RIS_KEY, ) @merge_response_func( BaseSupersetModelRestApi.merge_list_columns, API_LIST_COLUMNS_RIS_KEY ) @merge_response_func( BaseSupersetModelRestApi.merge_list_title, API_LIST_TITLE_RIS_KEY ) @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_list", log_to_statsd=False, ) @handle_api_exception def get_list(self, **kwargs: Any) -> Response: """Get a list of dashboards. --- get: summary: Get a list of dashboards parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_list_schema' responses: 200: description: Dashboards content: application/json: schema: type: object properties: ids: type: array items: type: integer count: type: integer result: type: array items: type: object 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ return super().get_list(**kwargs) list_select_columns = list_columns + ["changed_on", "created_on", "changed_by_fk"] order_columns = [ "changed_by.first_name", "changed_on_delta_humanized", "created_by.first_name", "dashboard_title", "published", "changed_on", ] add_columns = [ "certified_by", "certification_details", "dashboard_title", "slug", "owners", "roles", "position_json", "css", "theme_id", "json_metadata", "published", ] edit_columns = add_columns search_columns = ( "created_by", "changed_by", "dashboard_title", "id", "uuid", "owners", "published", "roles", "slug", "tags", "uuid", ) search_filters = { "dashboard_title": [DashboardTitleOrSlugFilter], "id": [DashboardFavoriteFilter, DashboardCertifiedFilter], "created_by": [DashboardCreatedByMeFilter, DashboardHasCreatedByFilter], "tags": [DashboardTagIdFilter, DashboardTagNameFilter], } base_order = ("changed_on", "desc") add_model_schema = DashboardPostSchema() edit_model_schema = DashboardPutSchema() update_filters_model_schema = DashboardNativeFiltersConfigUpdateSchema() update_colors_model_schema = DashboardColorsConfigUpdateSchema() chart_entity_response_schema = ChartEntityResponseSchema() dashboard_get_response_schema = DashboardGetResponseSchema() dashboard_dataset_schema = DashboardDatasetSchema() tab_schema = TabsPayloadSchema() embedded_response_schema = EmbeddedDashboardResponseSchema() embedded_config_schema = EmbeddedDashboardConfigSchema() base_filters = [ ["id", DashboardAccessFilter, lambda: []], ] order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), "roles": ("name", "asc"), } base_related_field_filters = { "owners": [["id", BaseFilterRelatedUsers, lambda: []]], "created_by": [["id", BaseFilterRelatedUsers, lambda: []]], "changed_by": [["id", BaseFilterRelatedUsers, lambda: []]], "roles": [["id", BaseFilterRelatedRoles, lambda: []]], } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "roles": RelatedFieldFilter("name", FilterRelatedRoles), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), "changed_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } allowed_rel_fields = {"owners", "roles", "created_by", "changed_by"} openapi_spec_tag = "Dashboards" """ Override the name set for this collection of endpoints """ openapi_spec_component_schemas = ( ChartEntityResponseSchema, DashboardCacheScreenshotResponseSchema, DashboardCopySchema, DashboardGetResponseSchema, DashboardDatasetSchema, TabsPayloadSchema, GetFavStarIdsSchema, EmbeddedDashboardResponseSchema, DashboardScreenshotPostSchema, ) apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, "get_export_ids_schema": get_export_ids_schema, "thumbnail_query_schema": thumbnail_query_schema, "get_fav_star_ids_schema": get_fav_star_ids_schema, } openapi_spec_methods = openapi_spec_methods_override """ Overrides GET methods OpenApi descriptions """ def __repr__(self) -> str: """Deterministic string representation of the API instance for etag_cache.""" # pylint: disable=consider-using-f-string return "Superset.dashboards.api.DashboardRestApi@v{}{}".format( current_app.config["VERSION_STRING"], current_app.config["VERSION_SHA"], ) @expose("/", methods=("GET",)) @protect() @safe @statsd_metrics @with_dashboard @event_logger.log_this_with_extra_payload # pylint: disable=arguments-differ,arguments-renamed def get( self, dash: Dashboard, add_extra_log_payload: Callable[..., None] = lambda **kwargs: None, ) -> Response: """Get a dashboard. --- get: summary: Get a dashboard parameters: - in: path schema: type: string name: id_or_slug description: Either the id of the dashboard, or its slug responses: 200: description: Dashboard content: application/json: schema: type: object properties: result: $ref: '#/components/schemas/DashboardGetResponseSchema' 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' """ result = self.dashboard_get_response_schema.dump(dash) add_extra_log_payload( dashboard_id=dash.id, action=f"{self.__class__.__name__}.get" ) return self.response(200, result=result) @expose("//datasets", methods=("GET",)) @protect() @handle_api_exception @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_datasets", log_to_statsd=False, ) def get_datasets(self, id_or_slug: str) -> Response: """Get dashboard's datasets. --- get: summary: Get dashboard's datasets description: >- Returns a list of a dashboard's datasets. Each dataset includes only the information necessary to render the dashboard's charts. parameters: - in: path schema: type: string name: id_or_slug description: Either the id of the dashboard, or its slug responses: 200: description: Dashboard dataset definitions content: application/json: schema: type: object properties: result: type: array items: $ref: '#/components/schemas/DashboardDatasetSchema' 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' """ try: datasets = DashboardDAO.get_datasets_for_dashboard(id_or_slug) result = [ self.dashboard_dataset_schema.dump(dataset) for dataset in datasets ] return self.response(200, result=result) except (TypeError, ValueError) as err: raise DatasetValidationError(err) from err @expose("//tabs", methods=("GET",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_tabs", log_to_statsd=False, ) def get_tabs(self, id_or_slug: str) -> Response: """Get dashboard's tabs. --- get: summary: Get dashboard's tabs description: >- Returns a list of a dashboard's tabs and dashboard's nested tree structure for associated tabs. parameters: - in: path schema: type: string name: id_or_slug description: Either the id of the dashboard, or its slug responses: 200: description: Dashboard tabs content: application/json: schema: type: object properties: result: type: object items: $ref: '#/components/schemas/TabsPayloadSchema' 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' """ # noqa: E501 try: tabs = DashboardDAO.get_tabs_for_dashboard(id_or_slug) native_filters = DashboardDAO.get_native_filter_configuration(id_or_slug) result = self.tab_schema.dump(tabs) result["native_filters"] = native_filters return self.response(200, result=result) except (TypeError, ValueError) as err: return self.response_400( message=gettext( "Tab schema is invalid, caused by: %(error)s", error=str(err) ) ) except DashboardAccessDeniedError: return self.response_403() except DashboardNotFoundError: return self.response_404() @expose("//charts", methods=("GET",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_charts", log_to_statsd=False, ) def get_charts(self, id_or_slug: str) -> Response: """Get a dashboard's chart definitions. --- get: summary: Get a dashboard's chart definitions. parameters: - in: path schema: type: string name: id_or_slug responses: 200: description: Dashboard chart definitions content: application/json: schema: type: object properties: result: type: array items: $ref: '#/components/schemas/ChartEntityResponseSchema' 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' """ try: charts = DashboardDAO.get_charts_for_dashboard(id_or_slug) result = [self.chart_entity_response_schema.dump(chart) for chart in charts] return self.response(200, result=result) except DashboardAccessDeniedError: return self.response_403() except DashboardNotFoundError: return self.response_404() @expose("/", methods=("POST",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post", log_to_statsd=False, ) @requires_json def post(self) -> Response: """Create a new dashboard. --- post: summary: Create a new dashboard requestBody: description: Dashboard schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Dashboard added content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ try: item = self.add_model_schema.load(request.json) # This validates custom Schema with custom validations except ValidationError as error: return self.response_400(message=error.messages) try: new_model = CreateDashboardCommand(item).run() return self.response(201, id=new_model.id, result=item) except DashboardInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DashboardCreateFailedError as ex: logger.error( "Error creating model %s: %s", self.__class__.__name__, str(ex), exc_info=True, ) return self.response_422(message=str(ex)) @expose("/", methods=("PUT",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put", log_to_statsd=False, ) @requires_json def put(self, pk: int) -> Response: """Update a dashboard. --- put: summary: Update a dashboard parameters: - in: path schema: type: integer name: pk requestBody: description: Dashboard schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Dashboard changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' last_modified_time: type: number 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ try: item = self.edit_model_schema.load(request.json) # This validates custom Schema with custom validations except ValidationError as error: return self.response_400(message=error.messages) try: changed_model = UpdateDashboardCommand(pk, item).run() last_modified_time = changed_model.changed_on.replace( microsecond=0 ).timestamp() response = self.response( 200, id=changed_model.id, result=item, last_modified_time=last_modified_time, ) except DashboardNotFoundError: response = self.response_404() except DashboardForbiddenError: response = self.response_403() except TagForbiddenError as ex: response = self.response(403, message=str(ex)) except DashboardInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DashboardUpdateFailedError as ex: logger.error( "Error updating model %s: %s", self.__class__.__name__, str(ex), exc_info=True, ) response = self.response_422(message=str(ex)) return response @expose("//filters", methods=("PUT",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put_filters", log_to_statsd=False, ) @requires_json def put_filters(self, pk: int) -> Response: """ Modify native filters configuration for a dashboard. --- put: summary: Update native filters configuration for a dashboard. parameters: - in: path schema: type: integer name: pk requestBody: description: Native filters configuration required: true content: application/json: schema: $ref: '#/components/schemas/DashboardNativeFiltersConfigUpdateSchema' responses: 200: description: Dashboard native filters updated content: application/json: schema: type: object properties: result: type: array 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ try: item = self.update_filters_model_schema.load(request.json, partial=True) except ValidationError as error: return self.response_400(message=error.messages) try: configuration = UpdateDashboardNativeFiltersCommand(pk, item).run() response = self.response( 200, result=configuration, ) except DashboardNotFoundError: response = self.response_404() except DashboardForbiddenError: response = self.response_403() except TagForbiddenError as ex: response = self.response(403, message=str(ex)) except DashboardInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DashboardNativeFiltersUpdateFailedError as ex: logger.error( "Error changing native filters for dashboard %s: %s", self.__class__.__name__, str(ex), exc_info=True, ) response = self.response_422(message=str(ex)) return response @expose("//colors", methods=("PUT",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put_colors", log_to_statsd=False, ) @requires_json def put_colors(self, pk: int) -> Response: """ Modify colors configuration for a dashboard. --- put: summary: Update colors configuration for a dashboard. parameters: - in: path schema: type: integer name: pk - in: query name: mark_updated schema: type: boolean description: Whether to update the dashboard changed_on field requestBody: description: Colors configuration required: true content: application/json: schema: $ref: '#/components/schemas/DashboardColorsConfigUpdateSchema' responses: 200: description: Dashboard colors updated content: application/json: schema: type: object properties: result: type: array 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ try: item = self.update_colors_model_schema.load(request.json, partial=True) except ValidationError as error: return self.response_400(message=error.messages) try: mark_updated = parse_boolean_string( request.args.get("mark_updated", "true") ) UpdateDashboardColorsConfigCommand(pk, item, mark_updated).run() response = self.response(200) except DashboardNotFoundError: response = self.response_404() except DashboardForbiddenError: response = self.response_403() except DashboardInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DashboardColorsConfigUpdateFailedError as ex: logger.error( "Error changing color configuration for dashboard %s: %s", self.__class__.__name__, str(ex), exc_info=True, ) response = self.response_422(message=str(ex)) return response @expose("/", methods=("DELETE",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete", log_to_statsd=False, ) def delete(self, pk: int) -> Response: """Delete a dashboard. --- delete: summary: Delete a dashboard parameters: - in: path schema: type: integer name: pk responses: 200: description: Dashboard deleted content: application/json: schema: type: object properties: message: type: string 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ try: DeleteDashboardCommand([pk]).run() return self.response(200, message="OK") except DashboardNotFoundError: return self.response_404() except DashboardForbiddenError: return self.response_403() except DashboardDeleteFailedError as ex: logger.error( "Error deleting model %s: %s", self.__class__.__name__, str(ex), exc_info=True, ) return self.response_422(message=str(ex)) @expose("/", methods=("DELETE",)) @protect() @safe @statsd_metrics @rison(get_delete_ids_schema) @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete", log_to_statsd=False, ) def bulk_delete(self, **kwargs: Any) -> Response: """Bulk delete dashboards. --- delete: summary: Bulk delete dashboards parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_delete_ids_schema' responses: 200: description: Dashboard bulk delete content: application/json: schema: type: object properties: message: type: string 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ item_ids = kwargs["rison"] try: DeleteDashboardCommand(item_ids).run() return self.response( 200, message=ngettext( "Deleted %(num)d dashboard", "Deleted %(num)d dashboards", num=len(item_ids), ), ) except DashboardNotFoundError: return self.response_404() except DashboardForbiddenError: return self.response_403() except DashboardDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("/export/", methods=("GET",)) @protect() @safe @statsd_metrics @rison(get_export_ids_schema) @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.export", log_to_statsd=False, ) def export(self, **kwargs: Any) -> Response: """Download multiple dashboards as YAML files. --- get: summary: Download multiple dashboards as YAML files parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_export_ids_schema' responses: 200: description: Dashboard export content: text/plain: schema: type: string 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ requested_ids = kwargs["rison"] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"dashboard_export_{timestamp}" filename = f"{root}.zip" buf = BytesIO() with ZipFile(buf, "w") as bundle: try: for file_name, file_content in ExportDashboardsCommand( requested_ids ).run(): with bundle.open(f"{root}/{file_name}", "w") as fp: fp.write(file_content().encode()) except DashboardNotFoundError: return self.response_404() buf.seek(0) response = send_file( buf, mimetype="application/zip", as_attachment=True, download_name=filename, ) if token := request.args.get("token"): response.set_cookie(token, "done", max_age=600) return response @expose("//cache_dashboard_screenshot/", methods=("POST",)) @validate_feature_flags(["THUMBNAILS", "ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS"]) @protect() @rison(screenshot_query_schema) @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" f".cache_dashboard_screenshot", log_to_statsd=False, ) def cache_dashboard_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: """Compute and cache a screenshot. --- post: summary: Compute and cache a screenshot parameters: - in: path schema: type: integer name: pk requestBody: content: application/json: schema: $ref: '#/components/schemas/DashboardScreenshotPostSchema' responses: 202: description: Dashboard async result content: application/json: schema: $ref: "#/components/schemas/DashboardCacheScreenshotResponseSchema" 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ try: payload = CacheScreenshotSchema().load(request.json) except ValidationError as error: return self.response_400(message=error.messages) dashboard = cast(Dashboard, self.datamodel.get(pk, self._base_filters)) if not dashboard: return self.response_404() window_size = ( kwargs["rison"].get("window_size") or DEFAULT_DASHBOARD_WINDOW_SIZE ) # Don't shrink the image if thumb_size is not specified thumb_size = kwargs["rison"].get("thumb_size") or window_size force = kwargs["rison"].get("force", False) dashboard_state: DashboardPermalinkState = { "dataMask": payload.get("dataMask", {}), "activeTabs": payload.get("activeTabs", []), "anchor": payload.get("anchor", ""), "urlParams": payload.get("urlParams", []), } # if the permalink key is provided, dashboard_state will be ignored # else, create a permalink key from the dashboard_state permalink_key = ( payload.get("permalinkKey", None) or CreateDashboardPermalinkCommand( dashboard_id=str(dashboard.id), state=dashboard_state, ).run() ) dashboard_url = get_url_path("Superset.dashboard_permalink", key=permalink_key) screenshot_obj = DashboardScreenshot(dashboard_url, dashboard.digest) cache_key = screenshot_obj.get_cache_key(window_size, thumb_size, permalink_key) image_url = get_url_path( "DashboardRestApi.screenshot", pk=dashboard.id, digest=cache_key ) cache_payload = ( screenshot_obj.get_from_cache_key(cache_key) or ScreenshotCachePayload() ) def build_response(status_code: int) -> WerkzeugResponse: return self.response( status_code, cache_key=cache_key, dashboard_url=dashboard_url, image_url=image_url, task_updated_at=cache_payload.get_timestamp(), task_status=cache_payload.get_status(), ) if cache_payload.should_trigger_task(force): logger.info("Triggering screenshot ASYNC") screenshot_obj.cache.set(cache_key, ScreenshotCachePayload().to_dict()) cache_dashboard_screenshot.delay( username=get_current_user(), guest_token=( g.user.guest_token if get_current_user() and isinstance(g.user, GuestUser) else None ), dashboard_id=dashboard.id, dashboard_url=dashboard_url, thumb_size=thumb_size, window_size=window_size, cache_key=cache_key, force=force, ) return build_response(202) return build_response(200) @expose("//screenshot//", methods=("GET",)) @validate_feature_flags(["THUMBNAILS", "ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.screenshot", log_to_statsd=False, ) def screenshot(self, pk: int, digest: str) -> WerkzeugResponse: """Get a computed dashboard screenshot from cache. --- get: summary: Get a computed screenshot from cache parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest - in: query name: download_format schema: type: string enum: [png, pdf] responses: 200: description: Dashboard thumbnail image content: image/*: schema: type: string format: binary 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ dashboard = self.datamodel.get(pk, self._base_filters) # Making sure the dashboard still exists if not dashboard: return self.response_404() download_format = request.args.get("download_format", "png") # fetch the dashboard screenshot using the current user and cache if set if cache_payload := DashboardScreenshot.get_from_cache_key(digest): try: image = cache_payload.get_image() except ScreenshotImageNotAvailableException: return self.response_404() filename = get_filename( dashboard.dashboard_title or "screenshot", dashboard.id, skip_id=True ) if download_format == "pdf": pdf_img = image.getvalue() # Convert the screenshot to PDF pdf_data = build_pdf_from_screenshots([pdf_img]) return Response( pdf_data, mimetype="application/pdf", headers={ "Content-Disposition": f'attachment; filename="{filename}.pdf"' }, direct_passthrough=True, ) if download_format == "png": return Response( FileWrapper(image), mimetype="image/png", headers={ "Content-Disposition": f'attachment; filename="{filename}.png"' }, direct_passthrough=True, ) return self.response_404() @expose("//thumbnail//", methods=("GET",)) @validate_feature_flags(["THUMBNAILS"]) @protect() @safe @rison(thumbnail_query_schema) @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.thumbnail", log_to_statsd=False, ) def thumbnail(self, pk: int, digest: str, **kwargs: Any) -> WerkzeugResponse: """Compute async or get already computed dashboard thumbnail from cache. --- get: summary: Get dashboard's thumbnail description: >- Computes async or get already computed dashboard thumbnail from cache. parameters: - in: path schema: type: integer name: pk - in: path name: digest description: A hex digest that makes this dashboard unique schema: type: string responses: 200: description: Dashboard thumbnail image content: image/*: schema: type: string format: binary 202: description: Thumbnail does not exist on cache, fired async to compute content: application/json: schema: type: object properties: message: type: string 302: description: Redirects to the current digest 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ dashboard = cast(Dashboard, self.datamodel.get(pk, self._base_filters)) if not dashboard: return self.response_404() current_user = get_current_user() dashboard_url = get_url_path( "Superset.dashboard", dashboard_id_or_slug=dashboard.id ) if dashboard.digest != digest: self.incr_stats("redirect", self.thumbnail.__name__) return redirect( url_for( f"{self.__class__.__name__}.thumbnail", pk=pk, digest=dashboard.digest, ) ) screenshot_obj = DashboardScreenshot(dashboard_url, dashboard.digest) cache_key = screenshot_obj.get_cache_key() cache_payload = ( screenshot_obj.get_from_cache_key(cache_key) or ScreenshotCachePayload() ) image_url = get_url_path( "DashboardRestApi.thumbnail", pk=dashboard.id, digest=cache_key ) if cache_payload.should_trigger_task(): self.incr_stats("async", self.thumbnail.__name__) logger.info( "Triggering thumbnail compute (dashboard id: %s) ASYNC", str(dashboard.id), ) screenshot_obj.cache.set(cache_key, ScreenshotCachePayload().to_dict()) cache_dashboard_thumbnail.delay( current_user=current_user, dashboard_id=dashboard.id, force=False, cache_key=cache_key, ) return self.response( 202, cache_key=cache_key, dashboard_url=dashboard_url, image_url=image_url, task_updated_at=cache_payload.get_timestamp(), task_status=cache_payload.get_status(), ) self.incr_stats("from_cache", self.thumbnail.__name__) try: image = cache_payload.get_image() # Validate the BytesIO object is properly initialized if not image or not hasattr(image, "read"): logger.warning( "Thumbnail image object is invalid for dashboard %s", str(dashboard.id), ) return self.response_404() # Additional validation: ensure the BytesIO has content if image.getbuffer().nbytes == 0: logger.warning( "Thumbnail image is empty for dashboard %s", str(dashboard.id), ) return self.response_404() # Reset position to ensure reading from start image.seek(0) except ScreenshotImageNotAvailableException: return self.response_404() except Exception as ex: # pylint: disable=broad-except logger.error( "Error retrieving thumbnail for dashboard %s: %s", str(dashboard.id), str(ex), exc_info=True, ) return self.response_404() return Response( FileWrapper(image), mimetype="image/png", direct_passthrough=True, ) @expose("/favorite_status/", methods=("GET",)) @protect() @safe @statsd_metrics @rison(get_fav_star_ids_schema) @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" f".favorite_status", log_to_statsd=False, ) def favorite_status(self, **kwargs: Any) -> Response: """Check favorited dashboards for current user. --- get: summary: Check favorited dashboards for current user parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_fav_star_ids_schema' responses: 200: description: content: application/json: schema: $ref: "#/components/schemas/GetFavStarIdsSchema" 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ requested_ids = kwargs["rison"] dashboards = DashboardDAO.find_by_ids(requested_ids) if not dashboards: return self.response_404() favorited_dashboard_ids = DashboardDAO.favorited_ids(dashboards) res = [ {"id": request_id, "value": request_id in favorited_dashboard_ids} for request_id in requested_ids ] return self.response(200, result=res) @expose("//favorites/", methods=("POST",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.add_favorite", log_to_statsd=False, ) def add_favorite(self, pk: int) -> Response: """Mark the dashboard as favorite for the current user. --- post: summary: Mark the dashboard as favorite for the current user parameters: - in: path schema: type: integer name: pk responses: 200: description: Dashboard added to favorites content: application/json: schema: type: object properties: result: type: object 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ try: AddFavoriteDashboardCommand(pk).run() except DashboardNotFoundError: return self.response_404() except DashboardAccessDeniedError: return self.response_403() return self.response(200, result="OK") @expose("//favorites/", methods=("DELETE",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" f".remove_favorite", log_to_statsd=False, ) def remove_favorite(self, pk: int) -> Response: """Remove the dashboard from the user favorite list. --- delete: summary: Remove the dashboard from the user favorite list parameters: - in: path schema: type: integer name: pk responses: 200: description: Dashboard removed from favorites content: application/json: schema: type: object properties: result: type: object 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ try: DelFavoriteDashboardCommand(pk).run() except DashboardNotFoundError: return self.response_404() except DashboardAccessDeniedError: return self.response_403() return self.response(200, result="OK") @expose("/import/", methods=("POST",)) @protect() @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.import_", log_to_statsd=False, ) @requires_form_data def import_(self) -> Response: """Import dashboard(s) with associated charts/datasets/databases. --- post: summary: Import dashboard(s) with associated charts/datasets/databases requestBody: required: true content: multipart/form-data: schema: type: object properties: formData: description: upload file (ZIP or JSON) type: string format: binary passwords: description: >- JSON map of passwords for each featured database in the ZIP file. If the ZIP includes a database config in the path `databases/MyDatabase.yaml`, the password should be provided in the following format: `{"databases/MyDatabase.yaml": "my_password"}`. type: string overwrite: description: overwrite existing dashboards? type: boolean ssh_tunnel_passwords: description: >- JSON map of passwords for each ssh_tunnel associated to a featured database in the ZIP file. If the ZIP includes a ssh_tunnel config in the path `databases/MyDatabase.yaml`, the password should be provided in the following format: `{"databases/MyDatabase.yaml": "my_password"}`. type: string ssh_tunnel_private_keys: description: >- JSON map of private_keys for each ssh_tunnel associated to a featured database in the ZIP file. If the ZIP includes a ssh_tunnel config in the path `databases/MyDatabase.yaml`, the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key"}`. type: string ssh_tunnel_private_key_passwords: description: >- JSON map of private_key_passwords for each ssh_tunnel associated to a featured database in the ZIP file. If the ZIP includes a ssh_tunnel config in the path `databases/MyDatabase.yaml`, the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key_password"}`. type: string responses: 200: description: Dashboard import result content: application/json: schema: type: object properties: message: type: string 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ upload = request.files.get("formData") if not upload: return self.response_400() if is_zipfile(upload): with ZipFile(upload) as bundle: contents = get_contents_from_bundle(bundle) else: upload.seek(0) contents = {upload.filename: upload.read()} if not contents: raise NoValidFilesFoundError() passwords = ( json.loads(request.form["passwords"]) if "passwords" in request.form else None ) overwrite = request.form.get("overwrite") == "true" ssh_tunnel_passwords = ( json.loads(request.form["ssh_tunnel_passwords"]) if "ssh_tunnel_passwords" in request.form else None ) ssh_tunnel_private_keys = ( json.loads(request.form["ssh_tunnel_private_keys"]) if "ssh_tunnel_private_keys" in request.form else None ) ssh_tunnel_priv_key_passwords = ( json.loads(request.form["ssh_tunnel_private_key_passwords"]) if "ssh_tunnel_private_key_passwords" in request.form else None ) command = ImportDashboardsCommand( contents, passwords=passwords, overwrite=overwrite, ssh_tunnel_passwords=ssh_tunnel_passwords, ssh_tunnel_private_keys=ssh_tunnel_private_keys, ssh_tunnel_priv_key_passwords=ssh_tunnel_priv_key_passwords, ) command.run() return self.response(200, message="OK") @expose("//embedded", methods=("GET",)) @protect() @safe @permission_name("read") @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_embedded", log_to_statsd=False, ) @with_dashboard def get_embedded(self, dashboard: Dashboard) -> Response: """Get the dashboard's embedded configuration. --- get: summary: Get the dashboard's embedded configuration parameters: - in: path schema: type: string name: id_or_slug description: The dashboard id or slug responses: 200: description: Result contains the embedded dashboard config content: application/json: schema: type: object properties: result: $ref: '#/components/schemas/EmbeddedDashboardResponseSchema' 401: $ref: '#/components/responses/401' 500: $ref: '#/components/responses/500' """ if not dashboard.embedded: return self.response(404) embedded: EmbeddedDashboard = dashboard.embedded[0] result = self.embedded_response_schema.dump(embedded) return self.response(200, result=result) @expose("//embedded", methods=["POST", "PUT"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.set_embedded", log_to_statsd=False, ) @with_dashboard def set_embedded(self, dashboard: Dashboard) -> Response: """Set a dashboard's embedded configuration. --- post: summary: Set a dashboard's embedded configuration parameters: - in: path schema: type: string name: id_or_slug description: The dashboard id or slug requestBody: description: The embedded configuration to set required: true content: application/json: schema: EmbeddedDashboardConfigSchema responses: 200: description: Successfully set the configuration content: application/json: schema: type: object properties: result: $ref: '#/components/schemas/EmbeddedDashboardResponseSchema' 401: $ref: '#/components/responses/401' 500: $ref: '#/components/responses/500' put: description: >- Sets a dashboard's embedded configuration. parameters: - in: path schema: type: string name: id_or_slug description: The dashboard id or slug requestBody: description: The embedded configuration to set required: true content: application/json: schema: EmbeddedDashboardConfigSchema responses: 200: description: Successfully set the configuration content: application/json: schema: type: object properties: result: $ref: '#/components/schemas/EmbeddedDashboardResponseSchema' 401: $ref: '#/components/responses/401' 500: $ref: '#/components/responses/500' """ try: body = self.embedded_config_schema.load(request.json) embedded = EmbeddedDashboardDAO.upsert( dashboard, body["allowed_domains"], ) db.session.commit() # pylint: disable=consider-using-transaction result = self.embedded_response_schema.dump(embedded) return self.response(200, result=result) except ValidationError as error: db.session.rollback() # pylint: disable=consider-using-transaction return self.response_400(message=error.messages) @expose("//embedded", methods=("DELETE",)) @protect() @safe @permission_name("set_embedded") @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete_embedded", log_to_statsd=False, ) @with_dashboard def delete_embedded(self, dashboard: Dashboard) -> Response: """Delete a dashboard's embedded configuration. --- delete: summary: Delete a dashboard's embedded configuration parameters: - in: path schema: type: string name: id_or_slug description: The dashboard id or slug responses: 200: description: Successfully removed the configuration content: application/json: schema: type: object properties: message: type: string 401: $ref: '#/components/responses/401' 500: $ref: '#/components/responses/500' """ DeleteEmbeddedDashboardCommand(dashboard).run() return self.response(200, message="OK") @expose("//copy/", methods=("POST",)) @protect() @safe @permission_name("write") @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.copy_dash", log_to_statsd=False, ) @with_dashboard def copy_dash(self, original_dash: Dashboard) -> Response: """Create a copy of an existing dashboard. --- post: summary: Create a copy of an existing dashboard parameters: - in: path schema: type: string name: id_or_slug description: The dashboard id or slug requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DashboardCopySchema' responses: 200: description: Id of new dashboard and last modified time content: application/json: schema: type: object properties: id: type: number last_modified_time: type: number 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ try: data = DashboardCopySchema().load(request.json) except ValidationError as error: return self.response_400(message=error.messages) try: dash = CopyDashboardCommand(original_dash, data).run() except DashboardForbiddenError: return self.response_403() except DashboardCopyError: return self.response_400() return self.response( 200, result={ "id": dash.id, "last_modified_time": dash.changed_on.replace( microsecond=0 ).timestamp(), }, )