# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. # pylint: disable=too-many-lines import logging from datetime import datetime from io import BytesIO from typing import Any, cast, Optional from zipfile import is_zipfile, ZipFile from flask import redirect, request, Response, send_file, url_for from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.hooks import before_request from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext from marshmallow import ValidationError from werkzeug.wrappers import Response as WerkzeugResponse from werkzeug.wsgi import FileWrapper from superset import is_feature_enabled from superset.charts.filters import ( ChartAllTextFilter, ChartCertifiedFilter, ChartCreatedByMeFilter, ChartFavoriteFilter, ChartFilter, ChartHasCreatedByFilter, ChartOwnedCreatedFavoredByMeFilter, ChartTagIdFilter, ChartTagNameFilter, ) from superset.charts.schemas import ( CHART_SCHEMAS, ChartCacheWarmUpRequestSchema, ChartGetResponseSchema, ChartPostSchema, ChartPutSchema, get_delete_ids_schema, get_export_ids_schema, get_fav_star_ids_schema, openapi_spec_methods_override, screenshot_query_schema, thumbnail_query_schema, ) from superset.commands.chart.create import CreateChartCommand from superset.commands.chart.delete import DeleteChartCommand from superset.commands.chart.exceptions import ( ChartCreateFailedError, ChartDeleteFailedError, ChartForbiddenError, ChartInvalidError, ChartNotFoundError, ChartUpdateFailedError, DashboardsForbiddenError, ) from superset.commands.chart.export import ExportChartsCommand from superset.commands.chart.fave import AddFavoriteChartCommand from superset.commands.chart.importers.dispatcher import ImportChartsCommand from superset.commands.chart.unfave import DelFavoriteChartCommand from superset.commands.chart.update import UpdateChartCommand from superset.commands.chart.warm_up_cache import ChartWarmUpCacheCommand from superset.commands.exceptions import CommandException, TagForbiddenError from superset.commands.importers.exceptions import ( IncorrectFormatError, NoValidFilesFoundError, ) from superset.commands.importers.v1.utils import get_contents_from_bundle from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod from superset.daos.chart import ChartDAO from superset.exceptions import ScreenshotImageNotAvailableException from superset.extensions import event_logger from superset.models.slice import Slice from superset.tasks.thumbnails import cache_chart_thumbnail from superset.tasks.utils import get_current_user from superset.utils import json from superset.utils.screenshots import ( ChartScreenshot, DEFAULT_CHART_WINDOW_SIZE, ScreenshotCachePayload, StatusValues, ) from superset.utils.urls import get_url_path from superset.views.base_api import ( BaseSupersetModelRestApi, RelatedFieldFilter, requires_form_data, requires_json, statsd_metrics, ) from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners logger = logging.getLogger(__name__) 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", "add_favorite", "remove_favorite", "thumbnail", "screenshot", "cache_screenshot", "warm_up_cache", } class_permission_name = "Chart" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP list_columns = [ "is_managed_externally", "certified_by", "certification_details", "cache_timeout", "changed_by.first_name", "changed_by.last_name", "changed_by.id", "changed_by_name", "changed_on_delta_humanized", "changed_on_dttm", "changed_on_utc", "created_by.first_name", "created_by.id", "created_by.last_name", "created_by_name", "created_on_delta_humanized", "datasource_id", "datasource_name_text", "datasource_type", "datasource_url", "description", "description_markeddown", "edit_url", "form_data", "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", "dashboards.id", "dashboards.dashboard_title", "params", "slice_name", "slice_url", "table.default_endpoint", "table.table_name", "thumbnail_url", "url", "viz_type", "tags.id", "tags.name", "tags.type", "uuid", ] 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", "datasource_id", "datasource_name", "datasource_type", "description", "id", "uuid", "owners", "dashboards", "slice_name", "viz_type", "tags", "uuid", ] base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] search_filters = { "id": [ ChartFavoriteFilter, ChartCertifiedFilter, ChartOwnedCreatedFavoredByMeFilter, ], "slice_name": [ChartAllTextFilter], "created_by": [ChartHasCreatedByFilter, ChartCreatedByMeFilter], "tags": [ChartTagNameFilter, ChartTagIdFilter], } # Will just affect _info endpoint edit_columns = ["slice_name"] add_columns = edit_columns add_model_schema = ChartPostSchema() edit_model_schema = ChartPutSchema() chart_get_response_schema = ChartGetResponseSchema() 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"), } base_related_field_filters = { "owners": [["id", BaseFilterRelatedUsers, lambda: []]], "created_by": [["id", BaseFilterRelatedUsers, lambda: []]], "changed_by": [["id", BaseFilterRelatedUsers, lambda: []]], } related_field_filters = { "owners": RelatedFieldFilter("first_name", FilterRelatedOwners), "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), "changed_by": RelatedFieldFilter("first_name", FilterRelatedOwners), } allowed_rel_fields = {"owners", "created_by", "changed_by"} @expose("/", 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_uuid: str) -> Response: """Gets a chart --- get: description: >- Get a chart parameters: - in: path schema: type: string name: id_or_uuid description: Either the id of the chart, or its uuid responses: 200: description: Chart content: application/json: schema: type: object properties: result: $ref: '#/components/schemas/ChartGetResponseSchema' 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 = ChartDAO.get_by_id_or_uuid(id_or_uuid) result = self.chart_get_response_schema.dump(dash) return self.response(200, result=result) except ChartNotFoundError: return self.response_404() @expose("/", methods=("POST",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post", log_to_statsd=False, ) @requires_json def post(self) -> Response: """Create a new chart. --- post: summary: 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' 403: $ref: '#/components/responses/403' 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 DashboardsForbiddenError as ex: return self.response(ex.status, message=ex.message) 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("/", methods=("PUT",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put", log_to_statsd=False, ) @requires_json def put(self, pk: int) -> Response: """Update a chart. --- put: summary: Update 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 TagForbiddenError as ex: response = self.response(403, message=str(ex)) 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("/", methods=("DELETE",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete", log_to_statsd=False, ) def delete(self, pk: int) -> Response: """Delete a chart. --- delete: summary: Delete 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: """Bulk delete charts. --- delete: summary: Bulk delete charts 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: DeleteChartCommand(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 ChartDeleteFailedError as ex: return self.response_422(message=str(ex)) @expose("//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: """Compute and cache a screenshot. --- get: summary: 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" 202: description: Chart screenshot task created 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"] force = rison_dict.get("force") window_size = rison_dict.get("window_size") or DEFAULT_CHART_WINDOW_SIZE # Don't shrink the image if thumb_size is not specified thumb_size = rison_dict.get("thumb_size") or window_size chart = cast(Slice, 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) screenshot_obj = ChartScreenshot(chart_url, chart.digest) cache_key = screenshot_obj.get_cache_key(window_size, thumb_size) cache_payload = ( screenshot_obj.get_from_cache_key(cache_key) or ScreenshotCachePayload() ) image_url = get_url_path( "ChartRestApi.screenshot", pk=chart.id, digest=cache_key ) def build_response(status_code: int) -> WerkzeugResponse: return self.response( status_code, cache_key=cache_key, chart_url=chart_url, image_url=image_url, task_updated_at=cache_payload.get_timestamp(), task_status=cache_payload.get_status(), ) if cache_payload.should_trigger_task(force): logger.info("Triggering screenshot ASYNC") screenshot_obj.cache.set(cache_key, ScreenshotCachePayload().to_dict()) cache_chart_thumbnail.delay( current_user=get_current_user(), chart_id=chart.id, window_size=window_size, thumb_size=thumb_size, force=force, ) return build_response(202) return build_response(200) @expose("//screenshot//", 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 a computed screenshot from cache. --- get: summary: Get a computed screenshot from cache parameters: - in: path schema: type: integer name: pk - in: path schema: type: string name: digest responses: 200: description: Chart screenshot 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) if not chart: return self.response_404() if cache_payload := ChartScreenshot.get_from_cache_key(digest): if cache_payload.status == StatusValues.UPDATED: try: image = cache_payload.get_image() except ScreenshotImageNotAvailableException: return self.response_404() return Response( FileWrapper(image), mimetype="image/png", direct_passthrough=True, ) return self.response_404() @expose("//thumbnail//", 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: """Compute or get already computed chart thumbnail from cache. --- get: summary: Get chart thumbnail description: Compute or get already computed chart thumbnail from cache. parameters: - in: path schema: type: integer name: pk - in: path name: digest description: A hex digest that makes this chart unique schema: type: string 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 = cast(Slice, self.datamodel.get(pk, self._base_filters)) if not chart: return self.response_404() current_user = get_current_user() 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 ) ) url = get_url_path("Superset.slice", slice_id=chart.id) screenshot_obj = ChartScreenshot(url, chart.digest) cache_key = screenshot_obj.get_cache_key() cache_payload = ( screenshot_obj.get_from_cache_key(cache_key) or ScreenshotCachePayload() ) if cache_payload.should_trigger_task(): self.incr_stats("async", self.thumbnail.__name__) logger.info( "Triggering thumbnail compute (chart id: %s) ASYNC", str(chart.id) ) screenshot_obj.cache.set(cache_key, ScreenshotCachePayload().to_dict()) cache_chart_thumbnail.delay( current_user=current_user, chart_id=chart.id, force=False, ) return self.response( 202, task_updated_at=cache_payload.get_timestamp(), task_status=cache_payload.get_status(), ) self.incr_stats("from_cache", self.thumbnail.__name__) try: image = cache_payload.get_image() except ScreenshotImageNotAvailableException: return self.response_404() return Response( FileWrapper(image), 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: """Download multiple charts as YAML files. --- get: summary: Download multiple charts 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) response = send_file( buf, mimetype="application/zip", as_attachment=True, download_name=filename, ) if token := request.args.get("token"): response.set_cookie(token, "done", max_age=600) return response @expose("/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: """Check favorited charts for current user. --- get: summary: Check favorited charts 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("//favorites/", methods=("POST",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.add_favorite", log_to_statsd=False, ) def add_favorite(self, pk: int) -> Response: """Mark the chart as favorite for the current user. --- post: summary: Mark the chart as favorite for the current user parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart added to favorites content: application/json: schema: type: object properties: result: type: object 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ try: AddFavoriteChartCommand(pk).run() except ChartNotFoundError: return self.response_404() except ChartForbiddenError: return self.response_403() return self.response(200, result="OK") @expose("//favorites/", methods=("DELETE",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" f".remove_favorite", log_to_statsd=False, ) def remove_favorite(self, pk: int) -> Response: """Remove the chart from the user favorite list. --- delete: summary: Remove the chart from the user favorite list parameters: - in: path schema: type: integer name: pk responses: 200: description: Chart removed from favorites content: application/json: schema: type: object properties: result: type: object 401: $ref: '#/components/responses/401' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ try: DelFavoriteChartCommand(pk).run() except ChartNotFoundError: self.response_404() except ChartForbiddenError: self.response_403() return self.response(200, result="OK") @expose("/warm_up_cache", methods=("PUT",)) @protect() @safe @statsd_metrics @event_logger.log_this_with_context( action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.warm_up_cache", log_to_statsd=False, ) def warm_up_cache(self) -> Response: """Warm up the cache for the chart. --- put: summary: Warm up the cache for the chart description: >- Warms up the cache for the chart. Note for slices a force refresh occurs. In terms of the `extra_filters` these can be obtained from records in the JSON encoded `logs.json` column associated with the `explore_json` action. requestBody: description: >- Identifies the chart to warm up cache for, and any additional dashboard or filter context to use. required: true content: application/json: schema: $ref: "#/components/schemas/ChartCacheWarmUpRequestSchema" responses: 200: description: Each chart's warmup status content: application/json: schema: $ref: "#/components/schemas/ChartCacheWarmUpResponseSchema" 400: $ref: '#/components/responses/400' 404: $ref: '#/components/responses/404' 500: $ref: '#/components/responses/500' """ # noqa: E501 try: body = ChartCacheWarmUpRequestSchema().load(request.json) except ValidationError as error: return self.response_400(message=error.messages) try: result = ChartWarmUpCacheCommand( body["chart_id"], body.get("dashboard_id"), body.get("extra_filters"), ).run() return self.response(200, result=[result]) except CommandException as ex: return self.response(ex.status, message=ex.message) @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: summary: Import chart(s) with associated datasets and databases 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 ssh_tunnel_passwords: description: >- JSON map of passwords for each ssh_tunnel associated to a featured database in the ZIP file. If the ZIP includes a ssh_tunnel config in the path `databases/MyDatabase.yaml`, the password should be provided in the following format: `{"databases/MyDatabase.yaml": "my_password"}`. type: string ssh_tunnel_private_keys: description: >- JSON map of private_keys for each ssh_tunnel associated to a featured database in the ZIP file. If the ZIP includes a ssh_tunnel config in the path `databases/MyDatabase.yaml`, the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key"}`. type: string ssh_tunnel_private_key_passwords: description: >- JSON map of private_key_passwords for each ssh_tunnel associated to a featured database in the ZIP file. If the ZIP includes a ssh_tunnel config in the path `databases/MyDatabase.yaml`, the private_key should be provided in the following format: `{"databases/MyDatabase.yaml": "my_private_key_password"}`. type: string responses: 200: description: 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() if not is_zipfile(upload): raise IncorrectFormatError("Not a ZIP file") 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" ssh_tunnel_passwords = ( json.loads(request.form["ssh_tunnel_passwords"]) if "ssh_tunnel_passwords" in request.form else None ) ssh_tunnel_private_keys = ( json.loads(request.form["ssh_tunnel_private_keys"]) if "ssh_tunnel_private_keys" in request.form else None ) ssh_tunnel_priv_key_passwords = ( json.loads(request.form["ssh_tunnel_private_key_passwords"]) if "ssh_tunnel_private_key_passwords" in request.form else None ) command = ImportChartsCommand( contents, passwords=passwords, overwrite=overwrite, ssh_tunnel_passwords=ssh_tunnel_passwords, ssh_tunnel_private_keys=ssh_tunnel_private_keys, ssh_tunnel_priv_key_passwords=ssh_tunnel_priv_key_passwords, ) command.run() return self.response(200, message="OK")