class QueryRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Query) resource_name = "query" allow_browser_login = True include_route_methods = { RouteMethod.GET, RouteMethod.GET_LIST, RouteMethod.RELATED } class_permission_name = "QueryView" list_columns = [ "user.username", "database.database_name", "status", "start_time", "end_time", "rows", "tmp_table_name", "tracking_url", ] show_columns = [ "client_id", "tmp_table_name", "tmp_schema_name", "status", "tab_name", "sql_editor_id", "database.id", "schema", "sql", "select_sql", "executed_sql", "limit", "select_as_cta", "select_as_cta_used", "progress", "rows", "error_message", "results_key", "start_time", "start_running_time", "end_time", "end_result_backend_time", "tracking_url", "changed_on", ] base_filters = [["id", QueryFilter, lambda: []]] base_order = ("changed_on", "desc") openapi_spec_tag = "Queries" openapi_spec_methods = openapi_spec_methods_override related_field_filters = { "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } allowed_rel_fields = {"user"}
class DashboardRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Dashboard) @before_request(only=["thumbnail"]) def ensure_thumbnails_enabled(self) -> Optional[Response]: if not is_feature_enabled("THUMBNAILS"): return self.response_404() return None 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", "get_charts", "get_datasets", "get_embedded", "set_embedded", "delete_embedded", "thumbnail", } resource_name = "dashboard" allow_browser_login = True class_permission_name = "Dashboard" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP list_columns = [ "id", "published", "status", "slug", "url", "css", "position_json", "json_metadata", "thumbnail_url", "certified_by", "certification_details", "changed_by.first_name", "changed_by.last_name", "changed_by.username", "changed_by.id", "changed_by_name", "changed_by_url", "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.username", "owners.first_name", "owners.last_name", "owners.email", "roles.id", "roles.name", "is_managed_externally", ] 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", "json_metadata", "published", ] edit_columns = add_columns search_columns = ( "created_by", "changed_by", "dashboard_title", "id", "owners", "published", "roles", "slug", ) search_filters = { "dashboard_title": [DashboardTitleOrSlugFilter], "id": [DashboardFavoriteFilter, DashboardCertifiedFilter], "created_by": [DashboardCreatedByMeFilter], } base_order = ("changed_on", "desc") add_model_schema = DashboardPostSchema() edit_model_schema = DashboardPutSchema() chart_entity_response_schema = ChartEntityResponseSchema() dashboard_get_response_schema = DashboardGetResponseSchema() dashboard_dataset_schema = DashboardDatasetSchema() 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"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "roles": RelatedFieldFilter("name", FilterRelatedRoles), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } allowed_rel_fields = {"owners", "roles", "created_by"} openapi_spec_tag = "Dashboards" """ Override the name set for this collection of endpoints """ openapi_spec_component_schemas = ( ChartEntityResponseSchema, DashboardGetResponseSchema, DashboardDatasetSchema, GetFavStarIdsSchema, EmbeddedDashboardResponseSchema, ) 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.""" return "Superset.dashboards.api.DashboardRestApi@v{}{}".format( self.appbuilder.app.config["VERSION_STRING"], self.appbuilder.app.config["VERSION_SHA"], ) @etag_cache( get_last_modified=lambda _self, id_or_slug: DashboardDAO. get_dashboard_changed_on( # pylint: disable=line-too-long,useless-suppression id_or_slug), max_age=0, raise_for_access=lambda _self, id_or_slug: DashboardDAO. get_by_id_or_slug(id_or_slug), skip=lambda _self, id_or_slug: not is_feature_enabled("DASHBOARD_CACHE" ), ) @expose("/<id_or_slug>", methods=["GET"]) @protect() @safe @statsd_metrics @with_dashboard @event_logger.log_this_with_extra_payload # pylint: disable=arguments-differ def get( self, dash: Dashboard, add_extra_log_payload: Callable[..., None] = lambda **kwargs: None, ) -> Response: """Gets a dashboard --- get: description: >- 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) @etag_cache( get_last_modified=lambda _self, id_or_slug: DashboardDAO. get_dashboard_and_datasets_changed_on( # pylint: disable=line-too-long,useless-suppression id_or_slug), max_age=0, raise_for_access=lambda _self, id_or_slug: DashboardDAO. get_by_id_or_slug(id_or_slug), skip=lambda _self, id_or_slug: not is_feature_enabled("DASHBOARD_CACHE" ), ) @expose("/<id_or_slug>/datasets", methods=["GET"]) @protect() @safe @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: """Gets a dashboard's datasets --- get: 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: return self.response_400(message=gettext( "Dataset schema is invalid, caused by: %(error)s", error=str(err))) except DashboardAccessDeniedError: return self.response_403() except DashboardNotFoundError: return self.response_404() @etag_cache( get_last_modified=lambda _self, id_or_slug: DashboardDAO. get_dashboard_and_slices_changed_on( # pylint: disable=line-too-long,useless-suppression id_or_slug), max_age=0, raise_for_access=lambda _self, id_or_slug: DashboardDAO. get_by_id_or_slug(id_or_slug), skip=lambda _self, id_or_slug: not is_feature_enabled("DASHBOARD_CACHE" ), ) @expose("/<id_or_slug>/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: """Gets the chart definitions for a given dashboard --- get: description: >- Get the chart definitions for a given dashboard 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 ] if is_feature_enabled("REMOVE_SLICE_LEVEL_LABEL_COLORS"): # dashboard metadata has dashboard-level label_colors, # so remove slice-level label_colors from its form_data for chart in result: form_data = chart.get("form_data") form_data.pop("label_colors", None) 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: """Creates a new Dashboard --- post: description: >- 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("/<pk>", 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: """Changes a Dashboard --- put: description: >- Changes 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 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("/<pk>", 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: """Deletes a Dashboard --- delete: description: >- Deletes 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: """Delete bulk Dashboards --- delete: description: >- Deletes multiple Dashboards in a bulk operation. 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: BulkDeleteDashboardCommand(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 DashboardBulkDeleteFailedError 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, ) # pylint: disable=too-many-locals def export(self, **kwargs: Any) -> Response: """Export dashboards --- get: description: >- Exports multiple Dashboards and downloads them 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"] token = request.args.get("token") if is_feature_enabled("VERSIONED_EXPORT"): 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, attachment_filename=filename, ) if token: response.set_cookie(token, "done", max_age=600) return response query = self.datamodel.session.query(Dashboard).filter( Dashboard.id.in_(requested_ids)) query = self._base_filters.apply_all(query) ids = [item.id for item in query.all()] if not ids: return self.response_404() export = Dashboard.export_dashboards(ids) resp = make_response(export, 200) resp.headers["Content-Disposition"] = generate_download_headers( "json")["Content-Disposition"] if token: resp.set_cookie(token, "done", max_age=600) return resp @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) @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: """Get Dashboard thumbnail --- get: description: >- Compute 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 - in: query name: q content: application/json: schema: $ref: '#/components/schemas/thumbnail_query_schema' 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 = self.datamodel.get(pk, self._base_filters) if not dashboard: return self.response_404() dashboard_url = get_url_path("Superset.dashboard", dashboard_id_or_slug=dashboard.id) # If force, request a screenshot from the workers if kwargs["rison"].get("force", False): cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True) return self.response(202, message="OK Async") # fetch the dashboard screenshot using the current user and cache if set screenshot = DashboardScreenshot( dashboard_url, dashboard.digest).get_from_cache(cache=thumbnail_cache) # If the screenshot does not exist, request one from the workers if not screenshot: self.incr_stats("async", self.thumbnail.__name__) cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True) return self.response(202, message="OK Async") # If digests 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, )) self.incr_stats("from_cache", self.thumbnail.__name__) return Response(FileWrapper(screenshot), 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: """Favorite Stars for Dashboards --- get: description: >- 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("/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: 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 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" command = ImportDashboardsCommand(contents, passwords=passwords, overwrite=overwrite) command.run() return self.response(200, message="OK") @expose("/<id_or_slug>/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: """Response Returns the dashboard's embedded configuration --- get: description: >- Returns 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("/<id_or_slug>/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: """Response Sets a dashboard's embedded configuration. --- post: 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' 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 = EmbeddedDAO.upsert(dashboard, body["allowed_domains"]) result = self.embedded_response_schema.dump(embedded) return self.response(200, result=result) except ValidationError as error: return self.response_400(message=error.messages) @expose("/<id_or_slug>/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: """Response Removes a dashboard's embedded configuration. --- delete: description: >- Removes 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' """ for embedded in dashboard.embedded: DashboardDAO.delete(embedded) return self.response(200, message="OK")
class ChartRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Slice) resource_name = "chart" allow_browser_login = True include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "data", "viz_types", } class_permission_name = "SliceModelView" show_columns = [ "cache_timeout", "dashboards.dashboard_title", "dashboards.id", "description", "owners.first_name", "owners.id", "owners.last_name", "owners.username", "params", "slice_name", "viz_type", ] show_select_columns = show_columns + ["table.id"] list_columns = [ "cache_timeout", "changed_by.first_name", "changed_by.last_name", "changed_by_name", "changed_by_url", "changed_on_delta_humanized", "changed_on_utc", "datasource_id", "datasource_name_text", "datasource_type", "datasource_url", "description", "id", "params", "slice_name", "table.default_endpoint", "table.table_name", "thumbnail_url", "url", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "viz_type", ] list_select_columns = list_columns + ["changed_by_fk", "changed_on"] order_columns = [ "changed_by.first_name", "changed_on_delta_humanized", "datasource_id", "datasource_name", "slice_name", "viz_type", ] search_columns = [ "datasource_id", "datasource_name", "datasource_type", "description", "owners", "slice_name", "viz_type", ] base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] search_filters = {"slice_name": [ChartNameOrDescriptionFilter]} # Will just affect _info endpoint edit_columns = ["slice_name"] add_columns = edit_columns add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() openapi_spec_tag = "Charts" """ Override the name set for this collection of endpoints """ openapi_spec_component_schemas = CHART_SCHEMAS apispec_parameter_schemas = { "screenshot_query_schema": screenshot_query_schema, "get_delete_ids_schema": get_delete_ids_schema, } """ Add extra schemas to the OpenAPI components schema section """ openapi_spec_methods = openapi_spec_methods_override """ Overrides GET methods OpenApi descriptions """ order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) } allowed_rel_fields = {"owners"} def __init__(self) -> None: if is_feature_enabled("THUMBNAILS"): self.include_route_methods = self.include_route_methods | { "thumbnail", "screenshot", "cache_screenshot", } super().__init__() @expose("/", methods=["POST"]) @protect() @safe @statsd_metrics def post(self) -> Response: """Creates a new Chart --- post: description: >- Create a new Chart. requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Chart 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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 = CreateChartCommand(g.user, item).run() return self.response(201, id=new_model.id, result=item) except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartCreateFailedError as ex: logger.error("Error creating model %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @safe @statsd_metrics def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int) -> Response: """Changes a Chart --- put: description: >- Changes a Chart. parameters: - in: path schema: type: integer name: pk requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Chart changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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 = UpdateChartCommand(g.user, pk, item).run() return self.response(200, id=changed_model.id, result=item) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartUpdateFailedError as ex: logger.error("Error updating model %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe @statsd_metrics def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Chart --- delete: description: >- Deletes a Chart. parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart 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' """ try: DeleteChartCommand(g.user, pk).run() return self.response(200, message="OK") except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartDeleteFailedError as ex: logger.error("Error deleting model %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe @statsd_metrics @rison(get_delete_ids_schema) def bulk_delete(self, **kwargs: Any) -> Response: # pylint: disable=arguments-differ """Delete bulk Charts --- delete: description: >- Deletes multiple Charts in a bulk operation. parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_delete_ids_schema' responses: 200: description: Charts 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: BulkDeleteChartCommand(g.user, item_ids).run() return self.response( 200, message=ngettext("Deleted %(num)d chart", "Deleted %(num)d charts", num=len(item_ids)), ) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("/data", methods=["POST"]) @event_logger.log_this @protect() @safe @statsd_metrics def data(self) -> Response: # pylint: disable=too-many-return-statements """ Takes a query context constructed in the client and returns payload data response for the given query. --- post: description: >- Takes a query context constructed in the client and returns payload data response for the given query. requestBody: description: >- A query context consists of a datasource from which to fetch data and one or many query objects. required: true content: application/json: schema: $ref: "#/components/schemas/ChartDataQueryContextSchema" responses: 200: description: Query result content: application/json: schema: $ref: "#/components/schemas/ChartDataResponseSchema" 400: $ref: '#/components/responses/400' 500: $ref: '#/components/responses/500' """ if request.is_json: json_body = request.json elif request.form.get("form_data"): # CSV export submits regular form data json_body = json.loads(request.form["form_data"]) else: return self.response_400(message="Request is not JSON") try: query_context = ChartDataQueryContextSchema().load(json_body) except KeyError: return self.response_400(message="Request is incorrect") except ValidationError as error: return self.response_400(message=_( "Request is incorrect: %(error)s", error=error.messages)) try: query_context.raise_for_access() except SupersetSecurityException: return self.response_401() payload = query_context.get_payload() for query in payload: if query.get("error"): return self.response_400(message=f"Error: {query['error']}") result_format = query_context.result_format if result_format == ChartDataResultFormat.CSV: # return the first result result = payload[0]["data"] return CsvResponse( result, status=200, headers=generate_download_headers("csv"), mimetype="application/csv", ) if result_format == ChartDataResultFormat.JSON: response_data = simplejson.dumps({"result": payload}, default=json_int_dttm_ser, ignore_nan=True) resp = make_response(response_data, 200) resp.headers["Content-Type"] = "application/json; charset=utf-8" return resp return self.response_400( message=f"Unsupported result_format: {result_format}") @expose("/<pk>/cache_screenshot/", methods=["GET"]) @protect() @rison(screenshot_query_schema) @safe @statsd_metrics def cache_screenshot(self, pk: int, **kwargs: Dict[str, bool]) -> WerkzeugResponse: """ --- get: description: Compute and cache a screenshot. parameters: - in: path schema: type: integer name: pk - in: query name: q content: application/json: schema: $ref: '#/components/schemas/screenshot_query_schema' responses: 200: description: Chart async result content: application/json: schema: $ref: "#/components/schemas/ChartCacheScreenshotResponseSchema" 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ rison_dict = kwargs["rison"] window_size = rison_dict.get("window_size") or (800, 600) # Don't shrink the image if thumb_size is not specified thumb_size = rison_dict.get("thumb_size") or window_size chart = self.datamodel.get(pk, self._base_filters) if not chart: return self.response_404() chart_url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") screenshot_obj = ChartScreenshot(chart_url, chart.digest) cache_key = screenshot_obj.cache_key(window_size, thumb_size) image_url = get_url_path("ChartRestApi.screenshot", pk=chart.id, digest=cache_key) def trigger_celery() -> WerkzeugResponse: logger.info("Triggering screenshot ASYNC") kwargs = { "url": chart_url, "digest": chart.digest, "force": True, "window_size": window_size, "thumb_size": thumb_size, } cache_chart_thumbnail.delay(**kwargs) return self.response( 202, cache_key=cache_key, chart_url=chart_url, image_url=image_url, ) return trigger_celery() @expose("/<pk>/screenshot/<digest>/", methods=["GET"]) @protect() @rison(screenshot_query_schema) @safe @statsd_metrics def screenshot(self, pk: int, digest: str) -> WerkzeugResponse: """Get Chart screenshot --- get: description: Get a computed screenshot from cache. parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest responses: 200: description: Chart thumbnail image content: image/*: schema: type: string format: binary 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ chart = self.datamodel.get(pk, self._base_filters) # Making sure the chart still exists if not chart: return self.response_404() # TODO make sure the user has access to the chart # fetch the chart screenshot using the current user and cache if set img = ChartScreenshot.get_from_cache_key(thumbnail_cache, digest) if img: return Response(FileWrapper(img), mimetype="image/png", direct_passthrough=True) # TODO: return an empty image return self.response_404() @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) @protect() @rison(thumbnail_query_schema) @safe @statsd_metrics def thumbnail(self, pk: int, digest: str, **kwargs: Dict[str, bool]) -> WerkzeugResponse: """Get Chart thumbnail --- get: description: Compute or get already computed chart thumbnail from cache. parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest responses: 200: description: Chart thumbnail image content: image/*: schema: type: string format: binary 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ chart = self.datamodel.get(pk, self._base_filters) if not chart: return self.response_404() url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") if kwargs["rison"].get("force", False): logger.info("Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id)) cache_chart_thumbnail.delay(url, chart.digest, force=True) return self.response(202, message="OK Async") # fetch the chart screenshot using the current user and cache if set screenshot = ChartScreenshot( url, chart.digest).get_from_cache(cache=thumbnail_cache) # If not screenshot then send request to compute thumb to celery if not screenshot: logger.info("Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id)) cache_chart_thumbnail.delay(url, chart.digest, force=True) return self.response(202, message="OK Async") # If digests if chart.digest != digest: return redirect( url_for(f"{self.__class__.__name__}.thumbnail", pk=pk, digest=chart.digest)) return Response(FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True)
class ReportScheduleRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(ReportSchedule) include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined } class_permission_name = "ReportSchedule" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP resource_name = "report" allow_browser_login = True show_columns = [ "id", "active", "chart.id", "chart.slice_name", "context_markdown", "crontab", "dashboard.dashboard_title", "dashboard.id", "database.database_name", "database.id", "description", "grace_period", "last_eval_dttm", "last_state", "last_value", "last_value_row_json", "log_retention", "name", "owners.first_name", "owners.id", "owners.last_name", "recipients.id", "recipients.recipient_config_json", "recipients.type", "report_format", "sql", "type", "validator_config_json", "validator_type", "working_timeout", ] show_select_columns = show_columns + [ "chart.datasource_id", "chart.datasource_type", ] list_columns = [ "active", "changed_by.first_name", "changed_by.last_name", "changed_on", "changed_on_delta_humanized", "created_by.first_name", "created_by.last_name", "created_on", "crontab", "crontab_humanized", "id", "last_eval_dttm", "last_state", "name", "owners.first_name", "owners.id", "owners.last_name", "recipients.id", "recipients.type", "type", ] add_columns = [ "active", "chart", "context_markdown", "crontab", "dashboard", "database", "description", "grace_period", "log_retention", "name", "owners", "recipients", "report_format", "sql", "type", "validator_config_json", "validator_type", "working_timeout", ] edit_columns = add_columns add_model_schema = ReportSchedulePostSchema() edit_model_schema = ReportSchedulePutSchema() order_columns = [ "active", "created_by.first_name", "changed_by.first_name", "changed_on", "changed_on_delta_humanized", "created_on", "crontab", "last_eval_dttm", "name", "type", "crontab_humanized", ] search_columns = ["name", "active", "created_by", "type", "last_state"] search_filters = {"name": [ReportScheduleAllTextFilter]} allowed_rel_fields = { "owners", "chart", "dashboard", "database", "created_by" } filter_rel_fields = { "chart": [["id", ChartFilter, lambda: []]], "dashboard": [["id", DashboardAccessFilter, lambda: []]], "database": [["id", DatabaseFilter, lambda: []]], } text_field_rel_fields = { "dashboard": "dashboard_title", "chart": "slice_name", "database": "database_name", } related_field_filters = { "dashboard": "dashboard_title", "chart": "slice_name", "database": "database_name", "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), } apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, } openapi_spec_tag = "Report Schedules" openapi_spec_methods = openapi_spec_methods_override @expose("/<int:pk>", methods=["DELETE"]) @protect() @safe @statsd_metrics @permission_name("delete") def delete(self, pk: int) -> Response: """Delete a Report Schedule --- delete: description: >- Delete a Report Schedule parameters: - in: path schema: type: integer name: pk description: The report schedule pk responses: 200: description: Item deleted content: application/json: schema: type: object properties: message: type: string 403: $ref: '#/components/responses/403' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ try: DeleteReportScheduleCommand(g.user, pk).run() return self.response(200, message="OK") except ReportScheduleNotFoundError: return self.response_404() except ReportScheduleForbiddenError: return self.response_403() except ReportScheduleDeleteFailedError as ex: logger.error( "Error deleting report schedule %s: %s", self.__class__.__name__, str(ex), exc_info=True, ) return self.response_422(message=str(ex)) @expose("/", methods=["POST"]) @protect() @safe @statsd_metrics @permission_name("post") def post(self) -> Response: """Creates a new Report Schedule --- post: description: >- Create a new Report Schedule requestBody: description: Report Schedule schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Report schedule 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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 = CreateReportScheduleCommand(g.user, item).run() return self.response(201, id=new_model.id, result=item) except ReportScheduleNotFoundError as ex: return self.response_400(message=str(ex)) except ReportScheduleInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ReportScheduleCreateFailedError as ex: logger.error( "Error creating report schedule %s: %s", self.__class__.__name__, str(ex), exc_info=True, ) return self.response_422(message=str(ex)) @expose("/<int:pk>", methods=["PUT"]) @protect() @safe @statsd_metrics @permission_name("put") def put(self, pk: int) -> Response: # pylint: disable=too-many-return-statements """Updates an Report Schedule --- put: description: >- Updates a Report Schedule parameters: - in: path schema: type: integer name: pk description: The Report Schedule pk requestBody: description: Report Schedule schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Report Schedule changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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: new_model = UpdateReportScheduleCommand(g.user, pk, item).run() return self.response(200, id=new_model.id, result=item) except ReportScheduleNotFoundError: return self.response_404() except ReportScheduleInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ReportScheduleForbiddenError: return self.response_403() except ReportScheduleUpdateFailedError as ex: logger.error( "Error updating report %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) def bulk_delete(self, **kwargs: Any) -> Response: """Delete bulk Report Schedule layers --- delete: description: >- Deletes multiple report schedules in a bulk operation. parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_delete_ids_schema' responses: 200: description: Report Schedule 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: BulkDeleteReportScheduleCommand(g.user, item_ids).run() return self.response( 200, message=ngettext( "Deleted %(num)d report schedule", "Deleted %(num)d report schedules", num=len(item_ids), ), ) except ReportScheduleNotFoundError: return self.response_404() except ReportScheduleForbiddenError: return self.response_403() except ReportScheduleBulkDeleteFailedError as ex: return self.response_422(message=str(ex))
class DashboardRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Dashboard) include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined } resource_name = "dashboard" allow_browser_login = True class_permission_name = "DashboardModelView" show_columns = [ "id", "charts", "css", "dashboard_title", "json_metadata", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "changed_by_name", "changed_by_url", "changed_by.username", "changed_on", "position_json", "published", "url", "slug", "table_names", "thumbnail_url", ] order_columns = [ "dashboard_title", "changed_on", "published", "changed_by_fk" ] list_columns = [ "id", "published", "slug", "url", "css", "position_json", "json_metadata", "thumbnail_url", "changed_by.first_name", "changed_by.last_name", "changed_by.username", "changed_by.id", "changed_by_name", "changed_by_url", "changed_on", "dashboard_title", "owners.id", "owners.username", "owners.first_name", "owners.last_name", ] edit_columns = [ "dashboard_title", "slug", "owners", "position_json", "css", "json_metadata", "published", ] search_columns = ("dashboard_title", "slug", "owners", "published") search_filters = {"dashboard_title": [DashboardTitleOrSlugFilter]} add_columns = edit_columns base_order = ("changed_on", "desc") add_model_schema = DashboardPostSchema() edit_model_schema = DashboardPutSchema() base_filters = [["slice", DashboardFilter, lambda: []]] openapi_spec_tag = "Dashboards" order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) } allowed_rel_fields = {"owners"} openapi_spec_methods = openapi_spec_methods_override """ Overrides GET methods OpenApi descriptions """ def __init__(self) -> None: if is_feature_enabled("THUMBNAILS"): self.include_route_methods = self.include_route_methods | { "thumbnail" } super().__init__() @expose("/", methods=["POST"]) @protect() @safe @statsd_metrics def post(self) -> Response: """Creates a new Dashboard --- post: description: >- 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' 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.add_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: new_model = CreateDashboardCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) except DashboardInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DashboardCreateFailedError as ex: logger.error( f"Error creating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @safe @statsd_metrics def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int) -> Response: """Changes a Dashboard --- put: description: >- Changes 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' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.edit_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: changed_model = UpdateDashboardCommand(g.user, pk, item.data).run() return self.response(200, id=changed_model.id, result=item.data) except DashboardNotFoundError: return self.response_404() except DashboardForbiddenError: return self.response_403() except DashboardInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DashboardUpdateFailedError as ex: logger.error( f"Error updating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe @statsd_metrics def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Dashboard --- delete: description: >- Deletes 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(g.user, 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( f"Error deleting model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe @statsd_metrics @rison(get_delete_ids_schema) def bulk_delete(self, **kwargs: Any) -> Response: # pylint: disable=arguments-differ """Delete bulk Dashboards --- delete: description: >- Deletes multiple Dashboards in a bulk operation. parameters: - in: query name: q content: application/json: schema: type: array items: type: integer 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: BulkDeleteDashboardCommand(g.user, item_ids).run() return self.response( 200, message=ngettext( f"Deleted %(num)d dashboard", f"Deleted %(num)d dashboards", num=len(item_ids), ), ) except DashboardNotFoundError: return self.response_404() except DashboardForbiddenError: return self.response_403() except DashboardBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("/export/", methods=["GET"]) @protect() @safe @statsd_metrics @rison(get_export_ids_schema) def export(self, **kwargs: Any) -> Response: """Export dashboards --- get: description: >- Exports multiple Dashboards and downloads them as YAML files. parameters: - in: query name: q content: application/json: schema: type: array items: type: integer 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' """ query = self.datamodel.session.query(Dashboard).filter( Dashboard.id.in_(kwargs["rison"])) query = self._base_filters.apply_all(query) ids = [item.id for item in query.all()] if not ids: return self.response_404() export = Dashboard.export_dashboards(ids) resp = make_response(export, 200) resp.headers["Content-Disposition"] = generate_download_headers( "json")["Content-Disposition"] return resp @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) @protect() @safe @rison(thumbnail_query_schema) def thumbnail(self, pk: int, digest: str, **kwargs: Dict[str, bool]) -> WerkzeugResponse: """Get Dashboard thumbnail --- get: description: >- Compute 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 - in: query name: q content: application/json: schema: type: object properties: force: type: boolean default: false 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 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ dashboard = self.datamodel.get(pk, self._base_filters) if not dashboard: return self.response_404() # If force, request a screenshot from the workers if kwargs["rison"].get("force", False): cache_dashboard_thumbnail.delay(dashboard.id, force=True) return self.response(202, message="OK Async") # fetch the dashboard screenshot using the current user and cache if set screenshot = DashboardScreenshot(pk).get_from_cache( cache=thumbnail_cache) # If the screenshot does not exist, request one from the workers if not screenshot: cache_dashboard_thumbnail.delay(dashboard.id, force=True) return self.response(202, message="OK Async") # If digests if dashboard.digest != digest: return redirect( url_for( f"{self.__class__.__name__}.thumbnail", pk=pk, digest=dashboard.digest, )) return Response(FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True)
class ChartRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Slice) resource_name = "chart" allow_browser_login = True include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined } class_permission_name = "SliceModelView" show_columns = [ "slice_name", "description", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "dashboards.id", "dashboards.dashboard_title", "viz_type", "params", "cache_timeout", ] list_columns = [ "id", "slice_name", "url", "description", "changed_by.username", "changed_by_name", "changed_by_url", "changed_on", "datasource_name_text", "datasource_url", "viz_type", "params", "cache_timeout", ] order_columns = [ "slice_name", "viz_type", "datasource_name", "changed_by_fk", "changed_on", ] search_columns = ( "slice_name", "description", "viz_type", "datasource_name", "owners", ) base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] # Will just affect _info endpoint edit_columns = ["slice_name"] add_columns = edit_columns add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() openapi_spec_tag = "Charts" order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) } allowed_rel_fields = {"owners"} @expose("/", methods=["POST"]) @protect() @safe def post(self) -> Response: """Creates a new Chart --- post: description: >- Create a new Chart requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Chart 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.add_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: new_model = CreateChartCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartCreateFailedError as ex: logger.error( f"Error creating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @safe def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int) -> Response: """Changes a Chart --- put: description: >- Changes a Chart parameters: - in: path schema: type: integer name: pk requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Chart changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.edit_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: changed_model = UpdateChartCommand(g.user, pk, item.data).run() return self.response(200, id=changed_model.id, result=item.data) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartUpdateFailedError as ex: logger.error( f"Error updating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Chart --- delete: description: >- Deletes a Chart parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart 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' """ try: DeleteChartCommand(g.user, pk).run() return self.response(200, message="OK") except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartDeleteFailedError as ex: logger.error( f"Error deleting model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe @rison(get_delete_ids_schema) def bulk_delete(self, **kwargs: Any) -> Response: # pylint: disable=arguments-differ """Delete bulk Charts --- delete: description: >- Deletes multiple Charts in a bulk operation parameters: - in: query name: q content: application/json: schema: type: array items: type: integer responses: 200: description: Charts 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: BulkDeleteChartCommand(g.user, item_ids).run() return self.response( 200, message=ngettext( f"Deleted %(num)d chart", f"Deleted %(num)d charts", num=len(item_ids), ), ) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartBulkDeleteFailedError as ex: return self.response_422(message=str(ex))
class ChartRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Slice) resource_name = "chart" allow_browser_login = True include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "data", } class_permission_name = "SliceModelView" show_columns = [ "slice_name", "description", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "dashboards.id", "dashboards.dashboard_title", "viz_type", "params", "cache_timeout", ] list_columns = [ "id", "slice_name", "url", "description", "changed_by.username", "changed_by_name", "changed_by_url", "changed_on", "datasource_name_text", "datasource_url", "viz_type", "params", "cache_timeout", ] order_columns = [ "slice_name", "viz_type", "datasource_name", "changed_by_fk", "changed_on", ] search_columns = ( "slice_name", "description", "viz_type", "datasource_name", "owners", ) base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] search_filters = {"slice_name": [ChartNameOrDescriptionFilter]} # Will just affect _info endpoint edit_columns = ["slice_name"] add_columns = edit_columns add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() openapi_spec_tag = "Charts" order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) } allowed_rel_fields = {"owners"} @expose("/", methods=["POST"]) @protect() @safe def post(self) -> Response: """Creates a new Chart --- post: description: >- Create a new Chart requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Chart 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.add_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: new_model = CreateChartCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartCreateFailedError as ex: logger.error( f"Error creating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @safe def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int) -> Response: """Changes a Chart --- put: description: >- Changes a Chart parameters: - in: path schema: type: integer name: pk requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Chart changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.edit_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: changed_model = UpdateChartCommand(g.user, pk, item.data).run() return self.response(200, id=changed_model.id, result=item.data) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartUpdateFailedError as ex: logger.error( f"Error updating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Chart --- delete: description: >- Deletes a Chart parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart 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' """ try: DeleteChartCommand(g.user, pk).run() return self.response(200, message="OK") except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartDeleteFailedError as ex: logger.error( f"Error deleting model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe @rison(get_delete_ids_schema) def bulk_delete(self, **kwargs: Any) -> Response: # pylint: disable=arguments-differ """Delete bulk Charts --- delete: description: >- Deletes multiple Charts in a bulk operation parameters: - in: query name: q content: application/json: schema: type: array items: type: integer responses: 200: description: Charts 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: BulkDeleteChartCommand(g.user, item_ids).run() return self.response( 200, message=ngettext( f"Deleted %(num)d chart", f"Deleted %(num)d charts", num=len(item_ids), ), ) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("/data", methods=["POST"]) @event_logger.log_this @protect() @safe def data(self) -> Response: """ Takes a query context constructed in the client and returns payload data response for the given query. --- post: description: >- Takes a query context constructed in the client and returns payload data response for the given query. requestBody: description: Query context schema required: true content: application/json: schema: type: object properties: datasource: type: object description: The datasource where the query will run properties: id: type: integer type: type: string queries: type: array items: type: object properties: granularity: type: string groupby: type: array items: type: string metrics: type: array items: type: object filters: type: array items: type: string row_limit: type: integer responses: 200: description: Query result content: application/json: schema: type: array items: type: object properties: cache_key: type: string cached_dttm: type: string cache_timeout: type: integer error: type: string is_cached: type: boolean query: type: string status: type: string stacktrace: type: string rowcount: type: integer data: type: array items: type: object 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") try: query_context = QueryContext(**request.json) except KeyError: return self.response_400(message="Request is incorrect") try: security_manager.assert_query_context_permission(query_context) except SupersetSecurityException: return self.response_401() payload_json = query_context.get_payload() response_data = simplejson.dumps(payload_json, default=json_int_dttm_ser, ignore_nan=True) resp = make_response(response_data, 200) resp.headers["Content-Type"] = "application/json; charset=utf-8" return resp
class DatasetRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(SqlaTable) base_filters = [["id", DatasourceFilter, lambda: []]] resource_name = "dataset" allow_browser_login = True class_permission_name = "TableModelView" include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.RELATED, "refresh", } list_columns = [ "id", "database_id", "database_name", "changed_by_fk", "changed_by_name", "changed_by_url", "changed_by.username", "changed_on", "default_endpoint", "explore_url", "kind", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "schema", "sql", "table_name", ] show_columns = [ "database.database_name", "database.id", "table_name", "sql", "filter_select_enabled", "fetch_values_predicate", "schema", "description", "main_dttm_col", "offset", "default_endpoint", "cache_timeout", "is_sqllab_view", "template_params", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "columns", "metrics", ] add_model_schema = DatasetPostSchema() edit_model_schema = DatasetPutSchema() add_columns = ["database", "schema", "table_name", "owners"] edit_columns = [ "table_name", "sql", "filter_select_enabled", "fetch_values_predicate", "schema", "description", "main_dttm_col", "offset", "default_endpoint", "cache_timeout", "is_sqllab_view", "template_params", "owners", "columns", "metrics", ] openapi_spec_tag = "Datasets" related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "database": "database_name", } filter_rel_fields = {"database": [["id", DatabaseFilter, lambda: []]]} allowed_rel_fields = {"database", "owners"} @expose("/", methods=["POST"]) @protect() @safe @statsd_metrics def post(self) -> Response: """Creates a new Dataset --- post: description: >- Create a new Dataset requestBody: description: Dataset schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Dataset 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.add_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: new_model = CreateDatasetCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) except DatasetInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DatasetCreateFailedError as ex: logger.error("Error creating model %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @safe @statsd_metrics def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int) -> Response: """Changes a Dataset --- put: description: >- Changes a Dataset parameters: - in: path schema: type: integer name: pk requestBody: description: Dataset schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Dataset changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.edit_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: changed_model = UpdateDatasetCommand(g.user, pk, item.data).run() return self.response(200, id=changed_model.id, result=item.data) except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: return self.response_403() except DatasetInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DatasetUpdateFailedError as ex: logger.error("Error updating model %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe @statsd_metrics def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Dataset --- delete: description: >- Deletes a Dataset parameters: - in: path schema: type: integer name: pk responses: 200: description: Dataset 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' """ try: DeleteDatasetCommand(g.user, pk).run() return self.response(200, message="OK") except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: return self.response_403() except DatasetDeleteFailedError as ex: logger.error("Error deleting model %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/export/", methods=["GET"]) @protect() @safe @statsd_metrics @rison(get_export_ids_schema) def export(self, **kwargs: Any) -> Response: """Export dashboards --- get: description: >- Exports multiple datasets and downloads them as YAML files parameters: - in: query name: q content: application/json: schema: type: array items: type: integer responses: 200: description: Dataset export content: text/plain: schema: type: string 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ requested_ids = kwargs["rison"] query = self.datamodel.session.query(SqlaTable).filter( SqlaTable.id.in_(requested_ids)) query = self._base_filters.apply_all(query) items = query.all() ids = [item.id for item in items] if len(ids) != len(requested_ids): return self.response_404() data = [t.export_to_dict() for t in items] return Response( yaml.safe_dump(data), headers=generate_download_headers("yaml"), mimetype="application/text", ) @expose("/<pk>/refresh", methods=["PUT"]) @protect() @safe @statsd_metrics def refresh(self, pk: int) -> Response: """Refresh a Dataset --- put: description: >- Refreshes and updates columns of a dataset parameters: - in: path schema: type: integer name: pk responses: 200: description: Dataset 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' """ try: RefreshDatasetCommand(g.user, pk).run() return self.response(200, message="OK") except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: return self.response_403() except DatasetRefreshFailedError as ex: logger.error("Error refreshing dataset %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex))
class DatasetRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(SqlaTable) base_filters = [["id", DatasourceFilter, lambda: []]] resource_name = "dataset" allow_browser_login = True class_permission_name = "Dataset" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.IMPORT, RouteMethod.RELATED, RouteMethod.DISTINCT, "bulk_delete", "refresh", "related_objects", } list_columns = [ "id", "database.id", "database.database_name", "changed_by_name", "changed_by_url", "changed_by.first_name", "changed_by.username", "changed_on_utc", "changed_on_delta_humanized", "default_endpoint", "explore_url", "extra", "kind", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "schema", "sql", "table_name", ] list_select_columns = list_columns + ["changed_on", "changed_by_fk"] order_columns = [ "table_name", "schema", "changed_by.first_name", "changed_on_delta_humanized", "database.database_name", ] show_columns = [ "id", "database.database_name", "database.id", "table_name", "sql", "filter_select_enabled", "fetch_values_predicate", "schema", "description", "main_dttm_col", "offset", "default_endpoint", "cache_timeout", "is_sqllab_view", "template_params", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "columns", "metrics", "datasource_type", "url", "extra", ] add_model_schema = DatasetPostSchema() edit_model_schema = DatasetPutSchema() add_columns = ["database", "schema", "table_name", "owners"] edit_columns = [ "table_name", "sql", "filter_select_enabled", "fetch_values_predicate", "schema", "description", "main_dttm_col", "offset", "default_endpoint", "cache_timeout", "is_sqllab_view", "template_params", "owners", "columns", "metrics", "extra", ] openapi_spec_tag = "Datasets" related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "database": "database_name", } search_filters = {"sql": [DatasetIsNullOrEmptyFilter]} filter_rel_fields = {"database": [["id", DatabaseFilter, lambda: []]]} allowed_rel_fields = {"database", "owners"} allowed_distinct_fields = {"schema"} apispec_parameter_schemas = { "get_export_ids_schema": get_export_ids_schema, } openapi_spec_component_schemas = (DatasetRelatedObjectsResponse, ) @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, ) def post(self) -> Response: """Creates a new Dataset --- post: description: >- Create a new Dataset requestBody: description: Dataset schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Dataset 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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 = CreateDatasetCommand(g.user, item).run() return self.response(201, id=new_model.id, result=item) except DatasetInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except DatasetCreateFailedError as ex: logger.error("Error creating model %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/<pk>", 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, ) def put(self, pk: int) -> Response: """Changes a Dataset --- put: description: >- Changes a Dataset parameters: - in: path schema: type: integer name: pk - in: query schema: type: bool name: override_columns requestBody: description: Dataset schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Dataset changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ override_columns = (bool(strtobool(request.args["override_columns"])) if "override_columns" in request.args else False) if not request.is_json: return self.response_400(message="Request is not JSON") 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 = UpdateDatasetCommand(g.user, pk, item, override_columns).run() response = self.response(200, id=changed_model.id, result=item) except DatasetNotFoundError: response = self.response_404() except DatasetForbiddenError: response = self.response_403() except DatasetInvalidError as ex: response = self.response_422(message=ex.normalized_messages()) except DatasetUpdateFailedError as ex: logger.error("Error updating model %s: %s", self.__class__.__name__, str(ex)) response = self.response_422(message=str(ex)) return response @expose("/<pk>", 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: """Deletes a Dataset --- delete: description: >- Deletes a Dataset parameters: - in: path schema: type: integer name: pk responses: 200: description: Dataset 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' """ try: DeleteDatasetCommand(g.user, pk).run() return self.response(200, message="OK") except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: return self.response_403() except DatasetDeleteFailedError as ex: logger.error("Error deleting model %s: %s", self.__class__.__name__, str(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: """Export datasets --- get: description: >- Exports multiple datasets and downloads them as YAML files parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_export_ids_schema' responses: 200: description: Dataset export content: text/plain: schema: type: string 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ requested_ids = kwargs["rison"] if is_feature_enabled("VERSIONED_EXPORT"): timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"dataset_export_{timestamp}" filename = f"{root}.zip" buf = BytesIO() with ZipFile(buf, "w") as bundle: try: for file_name, file_content in ExportDatasetsCommand( requested_ids).run(): with bundle.open(f"{root}/{file_name}", "w") as fp: fp.write(file_content.encode()) except DatasetNotFoundError: return self.response_404() buf.seek(0) return send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) query = self.datamodel.session.query(SqlaTable).filter( SqlaTable.id.in_(requested_ids)) query = self._base_filters.apply_all(query) items = query.all() ids = [item.id for item in items] if len(ids) != len(requested_ids): return self.response_404() data = [t.export_to_dict() for t in items] return Response( yaml.safe_dump(data), headers=generate_download_headers("yaml"), mimetype="application/text", ) @expose("/<pk>/refresh", methods=["PUT"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" f".refresh", log_to_statsd=False, ) def refresh(self, pk: int) -> Response: """Refresh a Dataset --- put: description: >- Refreshes and updates columns of a dataset parameters: - in: path schema: type: integer name: pk responses: 200: description: Dataset 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' """ try: RefreshDatasetCommand(g.user, pk).run() return self.response(200, message="OK") except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: return self.response_403() except DatasetRefreshFailedError as ex: logger.error("Error refreshing dataset %s: %s", self.__class__.__name__, str(ex)) return self.response_422(message=str(ex)) @expose("/<pk>/related_objects", methods=["GET"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" f".related_objects", log_to_statsd=False, ) def related_objects(self, pk: int) -> Response: """Get charts and dashboards count associated to a dataset --- get: description: Get charts and dashboards count associated to a dataset parameters: - in: path name: pk schema: type: integer responses: 200: 200: description: Query result content: application/json: schema: $ref: "#/components/schemas/DatasetRelatedObjectsResponse" 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ dataset = DatasetDAO.find_by_id(pk) if not dataset: return self.response_404() data = DatasetDAO.get_related_objects(pk) charts = [{ "id": chart.id, "slice_name": chart.slice_name, "viz_type": chart.viz_type, } for chart in data["charts"]] dashboards = [{ "id": dashboard.id, "json_metadata": dashboard.json_metadata, "slug": dashboard.slug, "title": dashboard.dashboard_title, } for dashboard in data["dashboards"]] return self.response( 200, charts={ "count": len(charts), "result": charts }, dashboards={ "count": len(dashboards), "result": dashboards }, ) @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: """Delete bulk Datasets --- delete: description: >- Deletes multiple Datasets in a bulk operation. parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_delete_ids_schema' responses: 200: description: Dataset bulk delete content: application/json: schema: type: object properties: message: type: string 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' """ item_ids = kwargs["rison"] try: BulkDeleteDatasetCommand(g.user, item_ids).run() return self.response( 200, message=ngettext( "Deleted %(num)d dataset", "Deleted %(num)d datasets", num=len(item_ids), ), ) except DatasetNotFoundError: return self.response_404() except DatasetForbiddenError: return self.response_403() except DatasetBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("/import/", methods=["POST"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.import_", log_to_statsd=False, ) def import_(self) -> Response: """Import dataset(s) with associated databases --- post: requestBody: required: true content: multipart/form-data: schema: type: object properties: formData: description: upload file (ZIP or YAML) type: string format: binary passwords: description: JSON map of passwords for each file type: string overwrite: description: overwrite existing databases? type: bool responses: 200: description: Dataset 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()} passwords = (json.loads(request.form["passwords"]) if "passwords" in request.form else None) overwrite = request.form.get("overwrite") == "true" command = ImportDatasetsCommand(contents, passwords=passwords, overwrite=overwrite) try: command.run() return self.response(200, message="OK") except CommandInvalidError as exc: logger.warning("Import dataset failed") return self.response_422(message=exc.normalized_messages()) except DatasetImportError as exc: logger.error("Import dataset failed") return self.response_500(message=str(exc))
class ChartRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Slice) resource_name = "chart" allow_browser_login = True @before_request(only=["thumbnail", "screenshot", "cache_screenshot"]) def ensure_thumbnails_enabled(self) -> Optional[Response]: if not is_feature_enabled("THUMBNAILS"): return self.response_404() return None include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.IMPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "data", "data_from_cache", "viz_types", "favorite_status", "thumbnail", "screenshot", "cache_screenshot", } class_permission_name = "Chart" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP show_columns = [ "cache_timeout", "dashboards.dashboard_title", "dashboards.id", "description", "owners.first_name", "owners.id", "owners.last_name", "owners.username", "params", "slice_name", "viz_type", ] show_select_columns = show_columns + ["table.id"] list_columns = [ "cache_timeout", "changed_by.first_name", "changed_by.last_name", "changed_by_name", "changed_by_url", "changed_on_delta_humanized", "changed_on_utc", "created_by.first_name", "created_by.id", "created_by.last_name", "datasource_id", "datasource_name_text", "datasource_type", "datasource_url", "description", "description_markeddown", "edit_url", "id", "owners.first_name", "owners.id", "owners.last_name", "owners.username", "params", "slice_name", "table.default_endpoint", "table.table_name", "thumbnail_url", "url", "viz_type", ] list_select_columns = list_columns + ["changed_by_fk", "changed_on"] order_columns = [ "changed_by.first_name", "changed_on_delta_humanized", "datasource_id", "datasource_name", "slice_name", "viz_type", ] search_columns = [ "created_by", "changed_by", "datasource_id", "datasource_name", "datasource_type", "description", "id", "owners", "slice_name", "viz_type", ] base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] search_filters = { "id": [ChartFavoriteFilter], "slice_name": [ChartAllTextFilter], } # Will just affect _info endpoint edit_columns = ["slice_name"] add_columns = edit_columns add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() openapi_spec_tag = "Charts" """ Override the name set for this collection of endpoints """ openapi_spec_component_schemas = CHART_SCHEMAS apispec_parameter_schemas = { "screenshot_query_schema": screenshot_query_schema, "get_delete_ids_schema": get_delete_ids_schema, "get_export_ids_schema": get_export_ids_schema, "get_fav_star_ids_schema": get_fav_star_ids_schema, } """ Add extra schemas to the OpenAPI components schema section """ openapi_spec_methods = openapi_spec_methods_override """ Overrides GET methods OpenApi descriptions """ order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } allowed_rel_fields = {"owners", "created_by"} @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, ) def post(self) -> Response: """Creates a new Chart --- post: description: >- Create a new Chart. requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Chart 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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 = CreateChartCommand(g.user, item).run() return self.response(201, id=new_model.id, result=item) except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartCreateFailedError 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("/<pk>", 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, ) def put(self, pk: int) -> Response: """Changes a Chart --- put: description: >- Changes a Chart. parameters: - in: path schema: type: integer name: pk requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Chart changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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 = UpdateChartCommand(g.user, pk, item).run() response = self.response(200, id=changed_model.id, result=item) except ChartNotFoundError: response = self.response_404() except ChartForbiddenError: response = self.response_403() except ChartInvalidError as ex: response = self.response_422(message=ex.normalized_messages()) except ChartUpdateFailedError 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("/<pk>", 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: """Deletes a Chart --- delete: description: >- Deletes a Chart. parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart 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' """ try: DeleteChartCommand(g.user, pk).run() return self.response(200, message="OK") except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartDeleteFailedError 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: """Delete bulk Charts --- delete: description: >- Deletes multiple Charts in a bulk operation. parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_delete_ids_schema' responses: 200: description: Charts 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: BulkDeleteChartCommand(g.user, item_ids).run() return self.response( 200, message=ngettext("Deleted %(num)d chart", "Deleted %(num)d charts", num=len(item_ids)), ) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) def get_data_response(self, command: ChartDataCommand, force_cached: bool = False) -> Response: try: result = command.run(force_cached=force_cached) except ChartDataCacheLoadError as exc: return self.response_422(message=exc.message) except ChartDataQueryFailedError as exc: return self.response_400(message=exc.message) result_format = result["query_context"].result_format if result_format == ChartDataResultFormat.CSV: # Verify user has permission to export CSV file if not security_manager.can_access("can_csv", "Superset"): return self.response_403() # return the first result data = result["queries"][0]["data"] return CsvResponse(data, headers=generate_download_headers("csv")) if result_format == ChartDataResultFormat.JSON: response_data = simplejson.dumps( {"result": result["queries"]}, default=json_int_dttm_ser, ignore_nan=True, ) resp = make_response(response_data, 200) resp.headers["Content-Type"] = "application/json; charset=utf-8" return resp return self.response_400( message=f"Unsupported result_format: {result_format}") @expose("/data", methods=["POST"]) @protect() @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.data", log_to_statsd=False, ) def data(self) -> Response: """ Takes a query context constructed in the client and returns payload data response for the given query. --- post: description: >- Takes a query context constructed in the client and returns payload data response for the given query. requestBody: description: >- A query context consists of a datasource from which to fetch data and one or many query objects. required: true content: application/json: schema: $ref: "#/components/schemas/ChartDataQueryContextSchema" responses: 200: description: Query result content: application/json: schema: $ref: "#/components/schemas/ChartDataResponseSchema" 202: description: Async job details content: application/json: schema: $ref: "#/components/schemas/ChartDataAsyncResponseSchema" 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 500: $ref: '#/components/responses/500' """ json_body = None if request.is_json: json_body = request.json elif request.form.get("form_data"): # CSV export submits regular form data try: json_body = json.loads(request.form["form_data"]) except (TypeError, json.JSONDecodeError): pass if json_body is None: return self.response_400(message=_("Request is not JSON")) try: command = ChartDataCommand() query_context = command.set_query_context(json_body) command.validate() except QueryObjectValidationError as error: return self.response_400(message=error.message) except ValidationError as error: return self.response_400( message=_("Request is incorrect: %(error)s", error=error.normalized_messages())) # TODO: support CSV, SQL query and other non-JSON types if (is_feature_enabled("GLOBAL_ASYNC_QUERIES") and query_context.result_format == ChartDataResultFormat.JSON and query_context.result_type == ChartDataResultType.FULL): try: command.validate_async_request(request) except AsyncQueryTokenException: return self.response_401() result = command.run_async(g.user.get_id()) return self.response(202, **result) return self.get_data_response(command) @expose("/data/<cache_key>", methods=["GET"]) @protect() @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" f".data_from_cache", log_to_statsd=False, ) def data_from_cache(self, cache_key: str) -> Response: """ Takes a query context cache key and returns payload data response for the given query. --- get: description: >- Takes a query context cache key and returns payload data response for the given query. parameters: - in: path schema: type: string name: cache_key responses: 200: description: Query result content: application/json: schema: $ref: "#/components/schemas/ChartDataResponseSchema" 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' """ command = ChartDataCommand() try: cached_data = command.load_query_context_from_cache(cache_key) command.set_query_context(cached_data) command.validate() except ChartDataCacheLoadError: return self.response_404() except ValidationError as error: return self.response_400(message=_( "Request is incorrect: %(error)s", error=error.messages)) return self.get_data_response(command, True) @expose("/<pk>/cache_screenshot/", methods=["GET"]) @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_screenshot", log_to_statsd=False, ) def cache_screenshot(self, pk: int, **kwargs: Dict[str, bool]) -> WerkzeugResponse: """ --- get: description: Compute and cache a screenshot. parameters: - in: path schema: type: integer name: pk - in: query name: q content: application/json: schema: $ref: '#/components/schemas/screenshot_query_schema' responses: 200: description: Chart async result content: application/json: schema: $ref: "#/components/schemas/ChartCacheScreenshotResponseSchema" 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ rison_dict = kwargs["rison"] window_size = rison_dict.get("window_size") or (800, 600) # Don't shrink the image if thumb_size is not specified thumb_size = rison_dict.get("thumb_size") or window_size chart = self.datamodel.get(pk, self._base_filters) if not chart: return self.response_404() chart_url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") screenshot_obj = ChartScreenshot(chart_url, chart.digest) cache_key = screenshot_obj.cache_key(window_size, thumb_size) image_url = get_url_path("ChartRestApi.screenshot", pk=chart.id, digest=cache_key) def trigger_celery() -> WerkzeugResponse: logger.info("Triggering screenshot ASYNC") kwargs = { "url": chart_url, "digest": chart.digest, "force": True, "window_size": window_size, "thumb_size": thumb_size, } cache_chart_thumbnail.delay(**kwargs) return self.response(202, cache_key=cache_key, chart_url=chart_url, image_url=image_url) return trigger_celery() @expose("/<pk>/screenshot/<digest>/", methods=["GET"]) @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 Chart screenshot --- get: description: Get a computed screenshot from cache. parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest responses: 200: description: Chart thumbnail image content: image/*: schema: type: string format: binary 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ chart = self.datamodel.get(pk, self._base_filters) # Making sure the chart still exists if not chart: return self.response_404() # fetch the chart screenshot using the current user and cache if set img = ChartScreenshot.get_from_cache_key(thumbnail_cache, digest) if img: return Response(FileWrapper(img), mimetype="image/png", direct_passthrough=True) # TODO: return an empty image return self.response_404() @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) @protect() @rison(thumbnail_query_schema) @safe @statsd_metrics @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: Dict[str, bool]) -> WerkzeugResponse: """Get Chart thumbnail --- get: description: Compute or get already computed chart thumbnail from cache. parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest responses: 200: description: Chart thumbnail image content: image/*: schema: type: string format: binary 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ chart = self.datamodel.get(pk, self._base_filters) if not chart: return self.response_404() url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") if kwargs["rison"].get("force", False): logger.info("Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id)) cache_chart_thumbnail.delay(url, chart.digest, force=True) return self.response(202, message="OK Async") # fetch the chart screenshot using the current user and cache if set screenshot = ChartScreenshot( url, chart.digest).get_from_cache(cache=thumbnail_cache) # If not screenshot then send request to compute thumb to celery if not screenshot: logger.info("Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id)) cache_chart_thumbnail.delay(url, chart.digest, force=True) return self.response(202, message="OK Async") # If digests if chart.digest != digest: return redirect( url_for(f"{self.__class__.__name__}.thumbnail", pk=pk, digest=chart.digest)) return Response(FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True) @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: """Export charts --- get: description: >- Exports multiple charts and downloads them as YAML files parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_export_ids_schema' responses: 200: description: A zip file with chart(s), dataset(s) and database(s) as YAML content: application/zip: 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' """ requested_ids = kwargs["rison"] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"chart_export_{timestamp}" filename = f"{root}.zip" buf = BytesIO() with ZipFile(buf, "w") as bundle: try: for file_name, file_content in ExportChartsCommand( requested_ids).run(): with bundle.open(f"{root}/{file_name}", "w") as fp: fp.write(file_content.encode()) except ChartNotFoundError: return self.response_404() buf.seek(0) return send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) @expose("/favorite_status/", methods=["GET"]) @protect() @safe @rison(get_fav_star_ids_schema) @statsd_metrics @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: """Favorite stars for Charts --- get: description: >- 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"] charts = ChartDAO.find_by_ids(requested_ids) if not charts: return self.response_404() favorited_chart_ids = ChartDAO.favorited_ids(charts, g.user.get_id()) res = [{ "id": request_id, "value": request_id in favorited_chart_ids } for request_id in requested_ids] return self.response(200, result=res) @expose("/import/", methods=["POST"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.import_", log_to_statsd=False, ) def import_(self) -> Response: """Import chart(s) with associated datasets and databases --- post: requestBody: required: true content: multipart/form-data: schema: type: object properties: formData: description: upload file (ZIP) type: string format: binary passwords: description: JSON map of passwords for each file type: string overwrite: description: overwrite existing databases? type: bool responses: 200: description: Chart 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() with ZipFile(upload) as bundle: contents = get_contents_from_bundle(bundle) passwords = (json.loads(request.form["passwords"]) if "passwords" in request.form else None) overwrite = request.form.get("overwrite") == "true" command = ImportChartsCommand(contents, passwords=passwords, overwrite=overwrite) try: command.run() return self.response(200, message="OK") except CommandInvalidError as exc: logger.warning("Import chart failed") return self.response_422(message=exc.normalized_messages()) except Exception as exc: # pylint: disable=broad-except logger.exception("Import chart failed") return self.response_500(message=str(exc))
class DashboardRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Dashboard) include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined } resource_name = "dashboard" allow_browser_login = True class_permission_name = "DashboardModelView" show_columns = [ "id", "charts", "css", "dashboard_title", "json_metadata", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "changed_by_name", "changed_by_url", "changed_by.username", "changed_on", "position_json", "published", "url", "slug", "table_names", ] order_columns = [ "dashboard_title", "changed_on", "published", "changed_by_fk" ] list_columns = [ "changed_by_name", "changed_by_url", "changed_by.username", "changed_on", "dashboard_title", "id", "published", "slug", "url", ] edit_columns = [ "dashboard_title", "slug", "owners", "position_json", "css", "json_metadata", "published", ] search_columns = ("dashboard_title", "slug", "owners", "published") add_columns = edit_columns base_order = ("changed_on", "desc") add_model_schema = DashboardPostSchema() edit_model_schema = DashboardPutSchema() base_filters = [["slice", DashboardFilter, lambda: []]] openapi_spec_tag = "Dashboards" order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) } allowed_rel_fields = {"owners"} @expose("/", methods=["POST"]) @protect() @safe def post(self) -> Response: """Creates a new Dashboard --- post: description: >- 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.add_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: new_model = CreateDashboardCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) except DashboardInvalidError as e: return self.response_422(message=e.normalized_messages()) except DashboardCreateFailedError as e: logger.error( f"Error creating model {self.__class__.__name__}: {e}") return self.response_422(message=str(e)) @expose("/<pk>", methods=["PUT"]) @protect() @safe def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int) -> Response: """Changes a Dashboard --- put: description: >- Changes 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' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.edit_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: changed_model = UpdateDashboardCommand(g.user, pk, item.data).run() return self.response(200, id=changed_model.id, result=item.data) except DashboardNotFoundError: return self.response_404() except DashboardForbiddenError: return self.response_403() except DashboardInvalidError as e: return self.response_422(message=e.normalized_messages()) except DashboardUpdateFailedError as e: logger.error( f"Error updating model {self.__class__.__name__}: {e}") return self.response_422(message=str(e)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Dashboard --- delete: description: >- Deletes 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(g.user, pk).run() return self.response(200, message="OK") except DashboardNotFoundError: return self.response_404() except DashboardForbiddenError: return self.response_403() except DashboardDeleteFailedError as e: logger.error( f"Error deleting model {self.__class__.__name__}: {e}") return self.response_422(message=str(e)) @expose("/", methods=["DELETE"]) @protect() @safe @rison(get_delete_ids_schema) def bulk_delete(self, **kwargs: Any) -> Response: # pylint: disable=arguments-differ """Delete bulk Dashboards --- delete: description: >- Deletes multiple Dashboards in a bulk operation parameters: - in: query name: q content: application/json: schema: type: array items: type: integer 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: BulkDeleteDashboardCommand(g.user, item_ids).run() return self.response( 200, message=ngettext( f"Deleted %(num)d dashboard", f"Deleted %(num)d dashboards", num=len(item_ids), ), ) except DashboardNotFoundError: return self.response_404() except DashboardForbiddenError: return self.response_403() except DashboardBulkDeleteFailedError as e: return self.response_422(message=str(e)) @expose("/export/", methods=["GET"]) @protect() @safe @rison(get_export_ids_schema) def export(self, **kwargs: Any) -> Response: """Export dashboards --- get: description: >- Exports multiple Dashboards and downloads them as YAML files parameters: - in: query name: q content: application/json: schema: type: array items: type: integer 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' """ query = self.datamodel.session.query(Dashboard).filter( Dashboard.id.in_(kwargs["rison"])) query = self._base_filters.apply_all(query) ids = [item.id for item in query.all()] if not ids: return self.response_404() export = Dashboard.export_dashboards(ids) resp = make_response(export, 200) resp.headers["Content-Disposition"] = generate_download_headers( "json")["Content-Disposition"] return resp
class QueryRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Query) resource_name = "query" class_permission_name = "Query" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP allow_browser_login = True include_route_methods = { RouteMethod.GET, RouteMethod.GET_LIST, RouteMethod.RELATED, RouteMethod.DISTINCT, } list_columns = [ "id", "changed_on", "database.database_name", "executed_sql", "rows", "schema", "sql", "sql_tables", "status", "tab_name", "user.first_name", "user.id", "user.last_name", "user.username", "start_time", "end_time", "tmp_table_name", "tracking_url", ] show_columns = [ "id", "changed_on", "client_id", "database.id", "end_result_backend_time", "end_time", "error_message", "executed_sql", "limit", "progress", "results_key", "rows", "schema", "select_as_cta", "select_as_cta_used", "select_sql", "sql", "sql_editor_id", "start_running_time", "start_time", "status", "tab_name", "tmp_schema_name", "tmp_table_name", "tracking_url", ] base_filters = [["id", QueryFilter, lambda: []]] base_order = ("changed_on", "desc") list_model_schema = QuerySchema() openapi_spec_tag = "Queries" openapi_spec_methods = openapi_spec_methods_override order_columns = [ "changed_on", "database.database_name", "rows", "schema", "start_time", "sql", "tab_name", "user.first_name", ] related_field_filters = { "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), "user": RelatedFieldFilter("first_name", FilterRelatedOwners), } search_columns = [ "changed_on", "database", "sql", "status", "user", "start_time" ] filter_rel_fields = {"database": [["id", DatabaseFilter, lambda: []]]} allowed_rel_fields = {"database", "user"} allowed_distinct_fields = {"status"}
class ChartRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Slice) resource_name = "chart" allow_browser_login = True @before_request(only=["thumbnail", "screenshot", "cache_screenshot"]) def ensure_thumbnails_enabled(self) -> Optional[Response]: if not is_feature_enabled("THUMBNAILS"): return self.response_404() return None include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.IMPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "viz_types", "favorite_status", "thumbnail", "screenshot", "cache_screenshot", } class_permission_name = "Chart" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP show_columns = [ "cache_timeout", "certified_by", "certification_details", "dashboards.dashboard_title", "dashboards.id", "dashboards.json_metadata", "description", "owners.first_name", "owners.id", "owners.last_name", "owners.username", "params", "slice_name", "viz_type", "query_context", "is_managed_externally", ] show_select_columns = show_columns + ["table.id"] list_columns = [ "is_managed_externally", "certified_by", "certification_details", "cache_timeout", "changed_by.first_name", "changed_by.last_name", "changed_by_name", "changed_by_url", "changed_on_delta_humanized", "changed_on_utc", "created_by.first_name", "created_by.id", "created_by.last_name", "datasource_id", "datasource_name_text", "datasource_type", "datasource_url", "description", "description_markeddown", "edit_url", "id", "last_saved_at", "last_saved_by.id", "last_saved_by.first_name", "last_saved_by.last_name", "owners.first_name", "owners.id", "owners.last_name", "owners.username", "params", "slice_name", "table.default_endpoint", "table.table_name", "thumbnail_url", "url", "viz_type", ] list_select_columns = list_columns + ["changed_by_fk", "changed_on"] order_columns = [ "changed_by.first_name", "changed_on_delta_humanized", "datasource_id", "datasource_name", "last_saved_at", "last_saved_by.id", "last_saved_by.first_name", "last_saved_by.last_name", "slice_name", "viz_type", ] search_columns = [ "created_by", "changed_by", "last_saved_at", "last_saved_by.id", "last_saved_by.first_name", "last_saved_by.last_name", "datasource_id", "datasource_name", "datasource_type", "description", "id", "owners", "slice_name", "viz_type", ] base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] search_filters = { "id": [ChartFavoriteFilter, ChartCertifiedFilter], "slice_name": [ChartAllTextFilter], } # Will just affect _info endpoint edit_columns = ["slice_name"] add_columns = edit_columns add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() openapi_spec_tag = "Charts" """ Override the name set for this collection of endpoints """ openapi_spec_component_schemas = CHART_SCHEMAS apispec_parameter_schemas = { "screenshot_query_schema": screenshot_query_schema, "get_delete_ids_schema": get_delete_ids_schema, "get_export_ids_schema": get_export_ids_schema, "get_fav_star_ids_schema": get_fav_star_ids_schema, } """ Add extra schemas to the OpenAPI components schema section """ openapi_spec_methods = openapi_spec_methods_override """ Overrides GET methods OpenApi descriptions """ order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } allowed_rel_fields = {"owners", "created_by"} @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: """Creates a new Chart --- post: description: >- Create a new Chart. requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Chart 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' 422: $ref: '#/components/responses/422' 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 = CreateChartCommand(item).run() return self.response(201, id=new_model.id, result=item) except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartCreateFailedError 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("/<pk>", 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: """Changes a Chart --- put: description: >- Changes a Chart. parameters: - in: path schema: type: integer name: pk requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Chart changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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 = UpdateChartCommand(pk, item).run() response = self.response(200, id=changed_model.id, result=item) except ChartNotFoundError: response = self.response_404() except ChartForbiddenError: response = self.response_403() except ChartInvalidError as ex: response = self.response_422(message=ex.normalized_messages()) except ChartUpdateFailedError 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("/<pk>", 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: """Deletes a Chart --- delete: description: >- Deletes a Chart. parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart 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' """ try: DeleteChartCommand(pk).run() return self.response(200, message="OK") except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartDeleteFailedError 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: """Delete bulk Charts --- delete: description: >- Deletes multiple Charts in a bulk operation. parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_delete_ids_schema' responses: 200: description: Charts 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: BulkDeleteChartCommand(item_ids).run() return self.response( 200, message=ngettext( "Deleted %(num)d chart", "Deleted %(num)d charts", num=len(item_ids) ), ) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("/<pk>/cache_screenshot/", methods=["GET"]) @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_screenshot", log_to_statsd=False, ) def cache_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse: """ --- get: description: Compute and cache a screenshot. parameters: - in: path schema: type: integer name: pk - in: query name: q content: application/json: schema: $ref: '#/components/schemas/screenshot_query_schema' responses: 202: description: Chart async result content: application/json: schema: $ref: "#/components/schemas/ChartCacheScreenshotResponseSchema" 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ rison_dict = kwargs["rison"] window_size = rison_dict.get("window_size") or (800, 600) # Don't shrink the image if thumb_size is not specified thumb_size = rison_dict.get("thumb_size") or window_size chart = self.datamodel.get(pk, self._base_filters) if not chart: return self.response_404() chart_url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") screenshot_obj = ChartScreenshot(chart_url, chart.digest) cache_key = screenshot_obj.cache_key(window_size, thumb_size) image_url = get_url_path( "ChartRestApi.screenshot", pk=chart.id, digest=cache_key ) def trigger_celery() -> WerkzeugResponse: logger.info("Triggering screenshot ASYNC") kwargs = { "url": chart_url, "digest": chart.digest, "force": True, "window_size": window_size, "thumb_size": thumb_size, } cache_chart_thumbnail.delay(**kwargs) return self.response( 202, cache_key=cache_key, chart_url=chart_url, image_url=image_url ) return trigger_celery() @expose("/<pk>/screenshot/<digest>/", methods=["GET"]) @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 Chart screenshot --- get: description: Get a computed screenshot from cache. parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest responses: 200: description: Chart 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' """ chart = self.datamodel.get(pk, self._base_filters) # Making sure the chart still exists if not chart: return self.response_404() # fetch the chart screenshot using the current user and cache if set img = ChartScreenshot.get_from_cache_key(thumbnail_cache, digest) if img: return Response( FileWrapper(img), mimetype="image/png", direct_passthrough=True ) # TODO: return an empty image return self.response_404() @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) @protect() @rison(thumbnail_query_schema) @safe @statsd_metrics @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: """Get Chart thumbnail --- get: description: Compute or get already computed chart thumbnail from cache. parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest responses: 200: description: Chart thumbnail image content: image/*: schema: type: string format: binary 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ chart = self.datamodel.get(pk, self._base_filters) if not chart: return self.response_404() url = get_url_path("Superset.slice", slice_id=chart.id, standalone="true") if kwargs["rison"].get("force", False): logger.info( "Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id) ) cache_chart_thumbnail.delay(url, chart.digest, force=True) return self.response(202, message="OK Async") # fetch the chart screenshot using the current user and cache if set screenshot = ChartScreenshot(url, chart.digest).get_from_cache( cache=thumbnail_cache ) # If not screenshot then send request to compute thumb to celery if not screenshot: self.incr_stats("async", self.thumbnail.__name__) logger.info( "Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id) ) cache_chart_thumbnail.delay(url, chart.digest, force=True) return self.response(202, message="OK Async") # If digests if chart.digest != digest: self.incr_stats("redirect", self.thumbnail.__name__) return redirect( url_for( f"{self.__class__.__name__}.thumbnail", pk=pk, digest=chart.digest ) ) self.incr_stats("from_cache", self.thumbnail.__name__) return Response( FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True ) @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: """Export charts --- get: description: >- Exports multiple charts and downloads them as YAML files parameters: - in: query name: q content: application/json: schema: $ref: '#/components/schemas/get_export_ids_schema' responses: 200: description: A zip file with chart(s), dataset(s) and database(s) as YAML content: application/zip: 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' """ token = request.args.get("token") requested_ids = kwargs["rison"] timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") root = f"chart_export_{timestamp}" filename = f"{root}.zip" buf = BytesIO() with ZipFile(buf, "w") as bundle: try: for file_name, file_content in ExportChartsCommand(requested_ids).run(): with bundle.open(f"{root}/{file_name}", "w") as fp: fp.write(file_content.encode()) except ChartNotFoundError: return self.response_404() buf.seek(0) response = send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) if token: response.set_cookie(token, "done", max_age=600) return response @expose("/favorite_status/", methods=["GET"]) @protect() @safe @rison(get_fav_star_ids_schema) @statsd_metrics @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: """Favorite stars for Charts --- get: description: >- 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"] charts = ChartDAO.find_by_ids(requested_ids) if not charts: return self.response_404() favorited_chart_ids = ChartDAO.favorited_ids(charts) res = [ {"id": request_id, "value": request_id in favorited_chart_ids} for request_id in requested_ids ] return self.response(200, result=res) @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 chart(s) with associated datasets and databases --- post: requestBody: required: true content: multipart/form-data: schema: type: object properties: formData: description: upload file (ZIP) 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 charts? type: boolean responses: 200: description: Chart 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() with ZipFile(upload) as bundle: contents = get_contents_from_bundle(bundle) if not contents: raise NoValidFilesFoundError() passwords = ( json.loads(request.form["passwords"]) if "passwords" in request.form else None ) overwrite = request.form.get("overwrite") == "true" command = ImportChartsCommand( contents, passwords=passwords, overwrite=overwrite ) command.run() return self.response(200, message="OK")
class DashboardRestApi(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", "get_charts", "get_datasets", } resource_name = "dashboard" allow_browser_login = True class_permission_name = "Dashboard" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP list_columns = [ "id", "published", "slug", "url", "css", "position_json", "json_metadata", "thumbnail_url", "changed_by.first_name", "changed_by.last_name", "changed_by.username", "changed_by.id", "changed_by_name", "changed_by_url", "changed_on_utc", "changed_on_delta_humanized", "created_by.first_name", "created_by.id", "created_by.last_name", "dashboard_title", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "roles.id", "roles.name", ] list_select_columns = list_columns + ["changed_on", "changed_by_fk"] order_columns = [ "changed_by.first_name", "changed_on_delta_humanized", "created_by.first_name", "dashboard_title", "published", ] add_columns = [ "dashboard_title", "slug", "owners", "roles", "position_json", "css", "json_metadata", "published", ] edit_columns = add_columns search_columns = ( "created_by", "dashboard_title", "id", "owners", "roles", "published", "slug", "changed_by", ) search_filters = { "dashboard_title": [DashboardTitleOrSlugFilter], "id": [DashboardFavoriteFilter], } base_order = ("changed_on", "desc") add_model_schema = DashboardPostSchema() edit_model_schema = DashboardPutSchema() chart_entity_response_schema = ChartEntityResponseSchema() dashboard_get_response_schema = DashboardGetResponseSchema() dashboard_dataset_schema = DashboardDatasetSchema() base_filters = [["slice", DashboardFilter, lambda: []]] order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), "roles": ("name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "roles": RelatedFieldFilter("name", FilterRelatedRoles), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } allowed_rel_fields = {"owners", "roles", "created_by"} openapi_spec_tag = "Dashboards" """ Override the name set for this collection of endpoints """ openapi_spec_component_schemas = ( ChartEntityResponseSchema, DashboardGetResponseSchema, DashboardDatasetSchema, GetFavStarIdsSchema, ) 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 __init__(self) -> None: if is_feature_enabled("THUMBNAILS"): self.include_route_methods = self.include_route_methods | {"thumbnail"} super().__init__() @expose("/<id_or_slug>", methods=["GET"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get", log_to_statsd=False, ) def get(self, id_or_slug: str) -> Response: """Gets a dashboard --- get: description: >- 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' 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' """ # pylint: disable=arguments-differ try: dash = DashboardDAO.get_by_id_or_slug(id_or_slug) result = self.dashboard_get_response_schema.dump(dash) return self.response(200, result=result) except DashboardNotFoundError: return self.response_404() @expose("/<id_or_slug>/datasets", methods=["GET"]) @protect() @safe @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: """Gets a dashboard's datasets --- get: 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' 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 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 DashboardNotFoundError: return self.response_404() @expose("/<pk>/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, pk: int) -> Response: """Gets the chart definitions for a given dashboard --- get: description: >- Get the chart definitions for a given dashboard parameters: - in: path schema: type: integer name: pk responses: 200: description: Dashboard chart definitions content: application/json: schema: type: object properties: result: type: array items: $ref: '#/components/schemas/ChartEntityResponseSchema' 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' """ try: charts = DashboardDAO.get_charts_for_dashboard(pk) result = [self.chart_entity_response_schema.dump(chart) for chart in charts] return self.response(200, result=result) 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, ) def post(self) -> Response: """Creates a new Dashboard --- post: description: >- 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' 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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(g.user, 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) ) return self.response_422(message=str(ex)) @expose("/<pk>", 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, ) def put(self, pk: int) -> Response: """Changes a Dashboard --- put: description: >- Changes 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' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") 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(g.user, pk, item).run() response = self.response(200, id=changed_model.id, result=item) 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 DashboardUpdateFailedError as ex: logger.error( "Error updating model %s: %s", self.__class__.__name__, str(ex) ) response = self.response_422(message=str(ex)) return response @expose("/<pk>", 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: """Deletes a Dashboard --- delete: description: >- Deletes 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(g.user, 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) ) 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: """Delete bulk Dashboards --- delete: description: >- Deletes multiple Dashboards in a bulk operation. 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: BulkDeleteDashboardCommand(g.user, 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 DashboardBulkDeleteFailedError 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: """Export dashboards --- get: description: >- Exports multiple Dashboards and downloads them 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"] if is_feature_enabled("VERSIONED_EXPORT"): 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) return send_file( buf, mimetype="application/zip", as_attachment=True, attachment_filename=filename, ) query = self.datamodel.session.query(Dashboard).filter( Dashboard.id.in_(requested_ids) ) query = self._base_filters.apply_all(query) ids = [item.id for item in query.all()] if not ids: return self.response_404() export = Dashboard.export_dashboards(ids) resp = make_response(export, 200) resp.headers["Content-Disposition"] = generate_download_headers("json")[ "Content-Disposition" ] return resp @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) @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: Dict[str, bool] ) -> WerkzeugResponse: """Get Dashboard thumbnail --- get: description: >- Compute 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 - in: query name: q content: application/json: schema: $ref: '#/components/schemas/thumbnail_query_schema' 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 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ dashboard = self.datamodel.get(pk, self._base_filters) if not dashboard: return self.response_404() dashboard_url = get_url_path( "Superset.dashboard", dashboard_id_or_slug=dashboard.id ) # If force, request a screenshot from the workers if kwargs["rison"].get("force", False): cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True) return self.response(202, message="OK Async") # fetch the dashboard screenshot using the current user and cache if set screenshot = DashboardScreenshot( dashboard_url, dashboard.digest ).get_from_cache(cache=thumbnail_cache) # If the screenshot does not exist, request one from the workers if not screenshot: cache_dashboard_thumbnail.delay(dashboard_url, dashboard.digest, force=True) return self.response(202, message="OK Async") # If digests if dashboard.digest != digest: return redirect( url_for( f"{self.__class__.__name__}.thumbnail", pk=pk, digest=dashboard.digest, ) ) return Response( FileWrapper(screenshot), 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: """Favorite Stars for Dashboards --- get: description: >- 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, g.user.id) res = [ {"id": request_id, "value": request_id in favorited_dashboard_ids} for request_id in requested_ids ] return self.response(200, result=res) @expose("/import/", methods=["POST"]) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.import_", log_to_statsd=False, ) def import_(self) -> Response: """Import dashboard(s) with associated charts/datasets/databases --- post: 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 file type: string overwrite: description: overwrite existing databases? type: bool 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()} passwords = ( json.loads(request.form["passwords"]) if "passwords" in request.form else None ) overwrite = request.form.get("overwrite") == "true" command = ImportDashboardsCommand( contents, passwords=passwords, overwrite=overwrite ) try: command.run() return self.response(200, message="OK") except CommandInvalidError as exc: logger.warning("Import dashboard failed") return self.response_422(message=exc.normalized_messages()) except DashboardImportError as exc: logger.exception("Import dashboard failed") return self.response_500(message=str(exc))
class ChartRestApi(BaseSupersetModelRestApi): datamodel = SQLAInterface(Slice) resource_name = "chart" allow_browser_login = True include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "data", "viz_types", "datasources", } class_permission_name = "SliceModelView" show_columns = [ "slice_name", "description", "owners.id", "owners.username", "owners.first_name", "owners.last_name", "dashboards.id", "dashboards.dashboard_title", "viz_type", "params", "cache_timeout", ] list_columns = [ "id", "slice_name", "url", "description", "changed_by_fk", "created_by_fk", "changed_by_name", "changed_by_url", "changed_by.first_name", "changed_by.last_name", "changed_on", "datasource_id", "datasource_type", "datasource_name_text", "datasource_url", "table.default_endpoint", "table.table_name", "viz_type", "params", "cache_timeout", ] order_columns = [ "slice_name", "viz_type", "datasource_name", "changed_by_fk", "changed_on", ] search_columns = ( "slice_name", "description", "viz_type", "datasource_name", "datasource_id", "datasource_type", "owners", ) base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] search_filters = {"slice_name": [ChartNameOrDescriptionFilter]} # Will just affect _info endpoint edit_columns = ["slice_name"] add_columns = edit_columns add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() openapi_spec_tag = "Charts" order_rel_fields = { "slices": ("slice_name", "asc"), "owners": ("first_name", "asc"), } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners) } allowed_rel_fields = {"owners"} def __init__(self) -> None: if is_feature_enabled("THUMBNAILS"): self.include_route_methods = self.include_route_methods | { "thumbnail" } super().__init__() @expose("/", methods=["POST"]) @protect() @safe @statsd_metrics def post(self) -> Response: """Creates a new Chart --- post: description: >- Create a new Chart requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.post' responses: 201: description: Chart 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' 422: $ref: '#/components/responses/422' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.add_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: new_model = CreateChartCommand(g.user, item.data).run() return self.response(201, id=new_model.id, result=item.data) except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartCreateFailedError as ex: logger.error( f"Error creating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["PUT"]) @protect() @safe @statsd_metrics def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int) -> Response: """Changes a Chart --- put: description: >- Changes a Chart parameters: - in: path schema: type: integer name: pk requestBody: description: Chart schema required: true content: application/json: schema: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' responses: 200: description: Chart changed content: application/json: schema: type: object properties: id: type: number result: $ref: '#/components/schemas/{{self.__class__.__name__}}.put' 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' """ if not request.is_json: return self.response_400(message="Request is not JSON") item = self.edit_model_schema.load(request.json) # This validates custom Schema with custom validations if item.errors: return self.response_400(message=item.errors) try: changed_model = UpdateChartCommand(g.user, pk, item.data).run() return self.response(200, id=changed_model.id, result=item.data) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartInvalidError as ex: return self.response_422(message=ex.normalized_messages()) except ChartUpdateFailedError as ex: logger.error( f"Error updating model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/<pk>", methods=["DELETE"]) @protect() @safe @statsd_metrics def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Chart --- delete: description: >- Deletes a Chart parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart 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' """ try: DeleteChartCommand(g.user, pk).run() return self.response(200, message="OK") except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartDeleteFailedError as ex: logger.error( f"Error deleting model {self.__class__.__name__}: {ex}") return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe @statsd_metrics @rison(get_delete_ids_schema) def bulk_delete(self, **kwargs: Any) -> Response: # pylint: disable=arguments-differ """Delete bulk Charts --- delete: description: >- Deletes multiple Charts in a bulk operation parameters: - in: query name: q content: application/json: schema: type: array items: type: integer responses: 200: description: Charts 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: BulkDeleteChartCommand(g.user, item_ids).run() return self.response( 200, message=ngettext( f"Deleted %(num)d chart", f"Deleted %(num)d charts", num=len(item_ids), ), ) except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() except ChartBulkDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("/data", methods=["POST"]) @event_logger.log_this @protect() @safe @statsd_metrics def data(self) -> Response: """ Takes a query context constructed in the client and returns payload data response for the given query. --- post: description: >- Takes a query context constructed in the client and returns payload data response for the given query. requestBody: description: >- A query context consists of a datasource from which to fetch data and one or many query objects. required: true content: application/json: schema: $ref: "#/components/schemas/ChartDataQueryContextSchema" responses: 200: description: Query result content: application/json: schema: $ref: "#/components/schemas/ChartDataResponseSchema" 400: $ref: '#/components/responses/400' 500: $ref: '#/components/responses/500' """ if not request.is_json: return self.response_400(message="Request is not JSON") try: query_context, errors = ChartDataQueryContextSchema().load( request.json) if errors: return self.response_400( message=_("Request is incorrect: %(error)s", error=errors)) except KeyError: return self.response_400(message="Request is incorrect") try: security_manager.assert_query_context_permission(query_context) except SupersetSecurityException: return self.response_401() payload_json = query_context.get_payload() response_data = simplejson.dumps({"result": payload_json}, default=json_int_dttm_ser, ignore_nan=True) resp = make_response(response_data, 200) resp.headers["Content-Type"] = "application/json; charset=utf-8" return resp @expose("/<pk>/thumbnail/<digest>/", methods=["GET"]) @protect() @rison(thumbnail_query_schema) @safe @statsd_metrics def thumbnail(self, pk: int, digest: str, **kwargs: Dict[str, bool]) -> WerkzeugResponse: """Get Chart thumbnail --- get: description: Compute or get already computed chart thumbnail from cache parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: sha responses: 200: description: Chart thumbnail image content: image/*: schema: type: string format: binary 302: description: Redirects to the current digest 400: $ref: '#/components/responses/400' 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ chart = self.datamodel.get(pk, self._base_filters) if not chart: return self.response_404() if kwargs["rison"].get("force", False): cache_chart_thumbnail.delay(chart.id, force=True) return self.response(202, message="OK Async") # fetch the chart screenshot using the current user and cache if set screenshot = ChartScreenshot(pk).get_from_cache(cache=thumbnail_cache) # If not screenshot then send request to compute thumb to celery if not screenshot: cache_chart_thumbnail.delay(chart.id, force=True) return self.response(202, message="OK Async") # If digests if chart.digest != digest: return redirect( url_for(f"{self.__class__.__name__}.thumbnail", pk=pk, digest=chart.digest)) return Response(FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True) def add_apispec_components(self, api_spec: APISpec) -> None: for chart_type in CHART_DATA_SCHEMAS: api_spec.components.schema( chart_type.__name__, schema=chart_type, ) super().add_apispec_components(api_spec) @expose("/datasources", methods=["GET"]) @protect() @safe def datasources(self) -> Response: """Get available datasources --- get: responses: 200: description: charts unique datasource data content: application/json: schema: type: object properties: count: type: integer result: type: object properties: label: type: string value: type: object properties: database_id: type: integer database_type: 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' """ datasources = ChartDAO.fetch_all_datasources() if not datasources: return self.response(200, count=0, result=[]) result = [{ "label": str(ds), "value": { "datasource_id": ds.id, "datasource_type": ds.type }, } for ds in datasources] return self.response(200, count=len(result), result=result)