From 74131509951b690f51eba442db0bc3db59c613aa Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 13 Apr 2020 13:48:15 +0100 Subject: [PATCH 01/10] [dashboard] New, add statsd incr to the API --- superset/dashboards/api.py | 26 ++++++++++++++++++++++++++ superset/views/base_api.py | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index eb4f796cd0d3..1c517e9e7f34 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -160,19 +160,25 @@ def post(self) -> Response: 500: $ref: '#/components/responses/500' """ + self.incr_stats("init", self.post.__name__) if not request.is_json: + self.incr_stats("error", self.post.__name__) 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: + self.incr_stats("error", self.post.__name__) return self.response_400(message=item.errors) try: new_model = CreateDashboardCommand(g.user, item.data).run() + self.incr_stats("success", self.post.__name__) return self.response(201, id=new_model.id, result=item.data) except DashboardInvalidError as ex: + self.incr_stats("error", self.post.__name__) return self.response_422(message=ex.normalized_messages()) except DashboardCreateFailedError as ex: logger.error(f"Error creating model {self.__class__.__name__}: {ex}") + self.incr_stats("error", self.post.__name__) return self.response_422(message=str(ex)) @expose("/", methods=["PUT"]) @@ -223,23 +229,31 @@ def put( # pylint: disable=too-many-return-statements, arguments-differ 500: $ref: '#/components/responses/500' """ + self.incr_stats("init", self.put.__name__) if not request.is_json: + self.incr_stats("error", self.put.__name__) 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: + self.incr_stats("error", self.put.__name__) return self.response_400(message=item.errors) try: changed_model = UpdateDashboardCommand(g.user, pk, item.data).run() + self.incr_stats("success", self.put.__name__) return self.response(200, id=changed_model.id, result=item.data) except DashboardNotFoundError: + self.incr_stats("error", self.put.__name__) return self.response_404() except DashboardForbiddenError: + self.incr_stats("error", self.put.__name__) return self.response_403() except DashboardInvalidError as ex: + self.incr_stats("error", self.put.__name__) return self.response_422(message=ex.normalized_messages()) except DashboardUpdateFailedError as ex: logger.error(f"Error updating model {self.__class__.__name__}: {ex}") + self.incr_stats("error", self.put.__name__) return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @@ -277,15 +291,20 @@ def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ 500: $ref: '#/components/responses/500' """ + self.incr_stats("init", self.delete.__name__) try: DeleteDashboardCommand(g.user, pk).run() + self.incr_stats("success", self.delete.__name__) return self.response(200, message="OK") except DashboardNotFoundError: + self.incr_stats("error", self.delete.__name__) return self.response_404() except DashboardForbiddenError: + self.incr_stats("error", self.delete.__name__) return self.response_403() except DashboardDeleteFailedError as ex: logger.error(f"Error deleting model {self.__class__.__name__}: {ex}") + self.incr_stats("error", self.delete.__name__) return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @@ -330,9 +349,11 @@ def bulk_delete( 500: $ref: '#/components/responses/500' """ + self.incr_stats("init", self.bulk_delete.__name__) item_ids = kwargs["rison"] try: BulkDeleteDashboardCommand(g.user, item_ids).run() + self.incr_stats("success", self.bulk_delete.__name__) return self.response( 200, message=ngettext( @@ -342,10 +363,13 @@ def bulk_delete( ), ) except DashboardNotFoundError: + self.incr_stats("error", self.bulk_delete.__name__) return self.response_404() except DashboardForbiddenError: + self.incr_stats("error", self.bulk_delete.__name__) return self.response_403() except DashboardBulkDeleteFailedError as ex: + self.incr_stats("error", self.bulk_delete.__name__) return self.response_422(message=str(ex)) @expose("/export/", methods=["GET"]) @@ -385,6 +409,7 @@ def export(self, **kwargs: Any) -> Response: 500: $ref: '#/components/responses/500' """ + self.incr_stats("init", self.bulk_delete.__name__) query = self.datamodel.session.query(Dashboard).filter( Dashboard.id.in_(kwargs["rison"]) ) @@ -397,4 +422,5 @@ def export(self, **kwargs: Any) -> Response: resp.headers["Content-Disposition"] = generate_download_headers("json")[ "Content-Disposition" ] + self.incr_stats("success", self.bulk_delete.__name__) return resp diff --git a/superset/views/base_api.py b/superset/views/base_api.py index d0c027dac67c..b8fa9cdd9c8e 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -18,7 +18,7 @@ import logging from typing import cast, Dict, Set, Tuple, Type, Union -from flask import request +from flask import request, Response from flask_appbuilder import ModelRestApi from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.filters import BaseFilter, Filters @@ -153,6 +153,33 @@ def _get_related_filter(self, datamodel, column_name: str, value: str) -> Filter def incr_stats(self, action: str, func_name: str) -> None: self.stats_logger.incr(f"{self.__class__.__name__}.{func_name}.{action}") + def info_headless(self, **kwargs) -> Response: + self.incr_stats("init", self.info.__name__) + response = super().info_headless(**kwargs) + if response.status_code == 200: + self.incr_stats("success", self.get.__name__) + else: + self.incr_stats("error", self.get.__name__) + return response + + def get_headless(self, pk, **kwargs) -> Response: + self.incr_stats("init", self.get.__name__) + response = super().get_headless(pk, **kwargs) + if response.status_code == 200: + self.incr_stats("success", self.get.__name__) + else: + self.incr_stats("error", self.get.__name__) + return response + + def get_list_headless(self, **kwargs) -> Response: + self.incr_stats("init", self.get_list.__name__) + response = super().get_list_headless(**kwargs) + if response.status_code == 200: + self.incr_stats("success", self.get.__name__) + else: + self.incr_stats("error", self.get.__name__) + return response + @expose("/related/", methods=["GET"]) @protect() @safe @@ -207,7 +234,9 @@ def related(self, column_name: str, **kwargs): 500: $ref: '#/components/responses/500' """ + self.incr_stats("init", self.related.__name__) if column_name not in self.allowed_rel_fields: + self.incr_stats("error", self.related.__name__) return self.response_404() args = kwargs.get("rison", {}) # handle pagination @@ -215,6 +244,7 @@ def related(self, column_name: str, **kwargs): try: datamodel = self.datamodel.get_related_interface(column_name) except KeyError: + self.incr_stats("error", self.related.__name__) return self.response_404() page, page_size = self._sanitize_page_args(page, page_size) # handle ordering @@ -234,6 +264,7 @@ def related(self, column_name: str, **kwargs): {"value": datamodel.get_pk_value(value), "text": str(value)} for value in values ] + self.incr_stats("success", self.related.__name__) return self.response(200, count=count, result=result) From d88c73c07c4446802769606dce6353fb9b170cfa Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Mon, 13 Apr 2020 17:34:02 +0100 Subject: [PATCH 02/10] [dashboard] improve metrics and DRY --- superset/dashboards/api.py | 37 +++++----------- superset/views/base_api.py | 87 +++++++++++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 46 deletions(-) diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 1c517e9e7f34..9348b7ae4206 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -45,7 +45,11 @@ ) from superset.models.dashboard import Dashboard from superset.views.base import generate_download_headers -from superset.views.base_api import BaseSupersetModelRestApi, RelatedFieldFilter +from superset.views.base_api import ( + BaseSupersetModelRestApi, + RelatedFieldFilter, + statsd_incr, +) from superset.views.filters import FilterRelatedOwners logger = logging.getLogger(__name__) @@ -126,6 +130,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): @expose("/", methods=["POST"]) @protect() @safe + @statsd_incr def post(self) -> Response: """Creates a new Dashboard --- @@ -160,30 +165,25 @@ def post(self) -> Response: 500: $ref: '#/components/responses/500' """ - self.incr_stats("init", self.post.__name__) if not request.is_json: - self.incr_stats("error", self.post.__name__) 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: - self.incr_stats("error", self.post.__name__) return self.response_400(message=item.errors) try: new_model = CreateDashboardCommand(g.user, item.data).run() - self.incr_stats("success", self.post.__name__) return self.response(201, id=new_model.id, result=item.data) except DashboardInvalidError as ex: - self.incr_stats("error", self.post.__name__) return self.response_422(message=ex.normalized_messages()) except DashboardCreateFailedError as ex: logger.error(f"Error creating model {self.__class__.__name__}: {ex}") - self.incr_stats("error", self.post.__name__) return self.response_422(message=str(ex)) @expose("/", methods=["PUT"]) @protect() @safe + @statsd_incr def put( # pylint: disable=too-many-return-statements, arguments-differ self, pk: int ) -> Response: @@ -229,36 +229,29 @@ def put( # pylint: disable=too-many-return-statements, arguments-differ 500: $ref: '#/components/responses/500' """ - self.incr_stats("init", self.put.__name__) if not request.is_json: - self.incr_stats("error", self.put.__name__) 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: - self.incr_stats("error", self.put.__name__) return self.response_400(message=item.errors) try: changed_model = UpdateDashboardCommand(g.user, pk, item.data).run() - self.incr_stats("success", self.put.__name__) return self.response(200, id=changed_model.id, result=item.data) except DashboardNotFoundError: - self.incr_stats("error", self.put.__name__) return self.response_404() except DashboardForbiddenError: - self.incr_stats("error", self.put.__name__) return self.response_403() except DashboardInvalidError as ex: - self.incr_stats("error", self.put.__name__) return self.response_422(message=ex.normalized_messages()) except DashboardUpdateFailedError as ex: logger.error(f"Error updating model {self.__class__.__name__}: {ex}") - self.incr_stats("error", self.put.__name__) return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe + @statsd_incr def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ """Deletes a Dashboard --- @@ -291,25 +284,21 @@ def delete(self, pk: int) -> Response: # pylint: disable=arguments-differ 500: $ref: '#/components/responses/500' """ - self.incr_stats("init", self.delete.__name__) try: DeleteDashboardCommand(g.user, pk).run() - self.incr_stats("success", self.delete.__name__) return self.response(200, message="OK") except DashboardNotFoundError: - self.incr_stats("error", self.delete.__name__) return self.response_404() except DashboardForbiddenError: - self.incr_stats("error", self.delete.__name__) return self.response_403() except DashboardDeleteFailedError as ex: logger.error(f"Error deleting model {self.__class__.__name__}: {ex}") - self.incr_stats("error", self.delete.__name__) return self.response_422(message=str(ex)) @expose("/", methods=["DELETE"]) @protect() @safe + @statsd_incr @rison(get_delete_ids_schema) def bulk_delete( self, **kwargs: Any @@ -349,11 +338,9 @@ def bulk_delete( 500: $ref: '#/components/responses/500' """ - self.incr_stats("init", self.bulk_delete.__name__) item_ids = kwargs["rison"] try: BulkDeleteDashboardCommand(g.user, item_ids).run() - self.incr_stats("success", self.bulk_delete.__name__) return self.response( 200, message=ngettext( @@ -363,18 +350,16 @@ def bulk_delete( ), ) except DashboardNotFoundError: - self.incr_stats("error", self.bulk_delete.__name__) return self.response_404() except DashboardForbiddenError: - self.incr_stats("error", self.bulk_delete.__name__) return self.response_403() except DashboardBulkDeleteFailedError as ex: - self.incr_stats("error", self.bulk_delete.__name__) return self.response_422(message=str(ex)) @expose("/export/", methods=["GET"]) @protect() @safe + @statsd_incr @rison(get_export_ids_schema) def export(self, **kwargs: Any) -> Response: """Export dashboards @@ -409,7 +394,6 @@ def export(self, **kwargs: Any) -> Response: 500: $ref: '#/components/responses/500' """ - self.incr_stats("init", self.bulk_delete.__name__) query = self.datamodel.session.query(Dashboard).filter( Dashboard.id.in_(kwargs["rison"]) ) @@ -422,5 +406,4 @@ def export(self, **kwargs: Any) -> Response: resp.headers["Content-Disposition"] = generate_download_headers("json")[ "Content-Disposition" ] - self.incr_stats("success", self.bulk_delete.__name__) return resp diff --git a/superset/views/base_api.py b/superset/views/base_api.py index b8fa9cdd9c8e..dcc00172aaea 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -16,7 +16,8 @@ # under the License. import functools import logging -from typing import cast, Dict, Set, Tuple, Type, Union +from timeit import default_timer +from typing import Any, cast, Dict, Optional, Set, Tuple, Type, Union from flask import request, Response from flask_appbuilder import ModelRestApi @@ -39,6 +40,21 @@ } +def statsd_incr(f): + """ + Handle sending all statsd metrics from the REST API + """ + + def wraps(self, *args: Any, **kwargs: Any) -> Response: + start = default_timer() + response = f(self, *args, **kwargs) + stop = default_timer() + self.send_stats_metrics(response, f.__name__, stop - start) + return response + + return functools.update_wrapper(wraps, f) + + def check_ownership_and_item_exists(f): """ A Decorator that checks if an object exists and is owned by the current user @@ -151,38 +167,74 @@ def _get_related_filter(self, datamodel, column_name: str, value: str) -> Filter return filters def incr_stats(self, action: str, func_name: str) -> None: + """ + Proxy function for statsd.incr to impose a key structure for REST API's + :param action: String with an action name eg: error, success + :param func_name: The function name + """ self.stats_logger.incr(f"{self.__class__.__name__}.{func_name}.{action}") + def timing_stats(self, action: str, func_name: str, value: float) -> None: + """ + Proxy function for statsd.incr to impose a key structure for REST API's + :param action: String with an action name eg: error, success + :param func_name: The function name + :param value: A float with the time it took for the endpoint to execute + """ + self.stats_logger.timing( + f"{self.__class__.__name__}.{func_name}.{action}", value + ) + + def send_stats_metrics( + self, response: Response, key: str, time_delta: Optional[float] = None + ) -> None: + """ + Helper function to handle sending statsd metrics + :param response: flask response object, will evaluate if it was an error + :param key: The function name + :param time_delta: Optional time it took for the endpoint to execute + """ + if 200 <= response.status_code < 400: + self.incr_stats("success", key) + else: + self.incr_stats("error", key) + if time_delta: + self.timing_stats("time", key, time_delta) + def info_headless(self, **kwargs) -> Response: - self.incr_stats("init", self.info.__name__) + """ + Add statsd metrics to builtin FAB _info endpoint + """ + start = default_timer() response = super().info_headless(**kwargs) - if response.status_code == 200: - self.incr_stats("success", self.get.__name__) - else: - self.incr_stats("error", self.get.__name__) + stop = default_timer() + self.send_stats_metrics(response, self.info.__name__, stop - start) return response def get_headless(self, pk, **kwargs) -> Response: - self.incr_stats("init", self.get.__name__) + """ + Add statsd metrics to builtin FAB GET endpoint + """ + start = default_timer() response = super().get_headless(pk, **kwargs) - if response.status_code == 200: - self.incr_stats("success", self.get.__name__) - else: - self.incr_stats("error", self.get.__name__) + stop = default_timer() + self.send_stats_metrics(response, self.get.__name__, stop - start) return response def get_list_headless(self, **kwargs) -> Response: - self.incr_stats("init", self.get_list.__name__) + """ + Add statsd metrics to builtin FAB GET list endpoint + """ + start = default_timer() response = super().get_list_headless(**kwargs) - if response.status_code == 200: - self.incr_stats("success", self.get.__name__) - else: - self.incr_stats("error", self.get.__name__) + stop = default_timer() + self.send_stats_metrics(response, self.get_list.__name__, stop - start) return response @expose("/related/", methods=["GET"]) @protect() @safe + @statsd_incr @rison(get_related_schema) def related(self, column_name: str, **kwargs): """Get related fields data @@ -234,7 +286,6 @@ def related(self, column_name: str, **kwargs): 500: $ref: '#/components/responses/500' """ - self.incr_stats("init", self.related.__name__) if column_name not in self.allowed_rel_fields: self.incr_stats("error", self.related.__name__) return self.response_404() @@ -244,7 +295,6 @@ def related(self, column_name: str, **kwargs): try: datamodel = self.datamodel.get_related_interface(column_name) except KeyError: - self.incr_stats("error", self.related.__name__) return self.response_404() page, page_size = self._sanitize_page_args(page, page_size) # handle ordering @@ -264,7 +314,6 @@ def related(self, column_name: str, **kwargs): {"value": datamodel.get_pk_value(value), "text": str(value)} for value in values ] - self.incr_stats("success", self.related.__name__) return self.response(200, count=count, result=result) From e5a974003bae2ff099dfba8db0d5183a71f203e2 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Tue, 14 Apr 2020 12:41:50 +0100 Subject: [PATCH 03/10] Add tests --- superset/views/base_api.py | 2 +- tests/base_tests.py | 80 ++++++++++++++++++++++++++++++++++- tests/dashboards/api_tests.py | 31 +++++++++----- 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/superset/views/base_api.py b/superset/views/base_api.py index ffc2f9820810..7b093c1b2823 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -210,7 +210,7 @@ def get_list_headless(self, **kwargs) -> Response: @expose("/related/", methods=["GET"]) @protect() @safe - @statsd_incr + @statsd_metrics @rison(get_related_schema) def related(self, column_name: str, **kwargs): """Get related fields data diff --git a/tests/base_tests.py b/tests/base_tests.py index 97c69f377871..cec3d66b4967 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -18,10 +18,11 @@ """Unit tests for Superset""" import imp import json -from typing import Union -from unittest.mock import Mock +from typing import Union, Dict +from unittest.mock import Mock, patch import pandas as pd +from flask import Response from flask_appbuilder.security.sqla import models as ab_models from flask_testing import TestCase @@ -35,6 +36,7 @@ from superset.models.dashboard import Dashboard from superset.models.datasource_access_request import DatasourceAccessRequest from superset.utils.core import get_example_database +from superset.views.base_api import BaseSupersetModelRestApi FAKE_DB_NAME = "fake_db_100" @@ -328,3 +330,77 @@ def validate_sql( def get_dash_by_slug(self, dash_slug): sesh = db.session() return sesh.query(Dashboard).filter_by(slug=dash_slug).first() + + def get_assert_metric(self, uri: str, func_name: str) -> Response: + """ + Simple client get with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP GET + :param func_name: The function name that the HTTP GET triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.get(uri) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv + + def delete_assert_metric(self, uri: str, func_name: str) -> Response: + """ + Simple client delete with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP DELETE + :param func_name: The function name that the HTTP DELETE triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.delete(uri) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv + + def post_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response: + """ + Simple client post with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP POST + :param data: The JSON data payload to be posted + :param func_name: The function name that the HTTP POST triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.post(uri, json=data) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv + + def put_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response: + """ + Simple client put with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP PUT + :param data: The JSON data payload to be posted + :param func_name: The function name that the HTTP PUT triggers + for the statsd metric assertion + :return: HTTP Response + """ + with patch.object( + BaseSupersetModelRestApi, "incr_stats", return_value=None + ) as mock_method: + rv = self.client.put(uri, json=data) + if 200 <= rv.status_code < 400: + mock_method.assert_called_once_with("success", func_name) + else: + mock_method.assert_called_once_with("error", func_name) + return rv diff --git a/tests/dashboards/api_tests.py b/tests/dashboards/api_tests.py index ced4b3daca94..6a0f039a7b45 100644 --- a/tests/dashboards/api_tests.py +++ b/tests/dashboards/api_tests.py @@ -85,7 +85,7 @@ def test_get_dashboard(self): dashboard = self.insert_dashboard("title", "slug1", [admin.id]) self.login(username="admin") uri = f"api/v1/dashboard/{dashboard.id}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get") self.assertEqual(rv.status_code, 200) expected_result = { "changed_by": None, @@ -120,6 +120,15 @@ def test_get_dashboard(self): db.session.delete(dashboard) db.session.commit() + def test_info_dashboard(self): + """ + Dashboard API: Test info + """ + self.login(username="admin") + uri = f"api/v1/dashboard/_info" + rv = self.get_assert_metric(uri, "info") + self.assertEqual(rv.status_code, 200) + def test_get_dashboard_not_found(self): """ Dashboard API: Test get dashboard not found @@ -127,7 +136,7 @@ def test_get_dashboard_not_found(self): max_id = db.session.query(func.max(Dashboard.id)).scalar() self.login(username="admin") uri = f"api/v1/dashboard/{max_id + 1}" - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "get") self.assertEqual(rv.status_code, 404) def test_get_dashboard_no_data_access(self): @@ -159,7 +168,8 @@ def test_get_dashboards_filter(self): "filters": [{"col": "dashboard_title", "opr": "sw", "value": "ti"}] } uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" - rv = self.client.get(uri) + + rv = self.get_assert_metric(uri, "get_list") self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 1) @@ -258,7 +268,7 @@ def test_delete_dashboard(self): dashboard_id = self.insert_dashboard("title", "slug1", [admin_id]).id self.login(username="admin") uri = f"api/v1/dashboard/{dashboard_id}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "delete") self.assertEqual(rv.status_code, 200) model = db.session.query(Dashboard).get(dashboard_id) self.assertEqual(model, None) @@ -281,7 +291,7 @@ def test_delete_bulk_dashboards(self): self.login(username="admin") argument = dashboard_ids uri = f"api/v1/dashboard/?q={prison.dumps(argument)}" - rv = self.client.delete(uri) + rv = self.delete_assert_metric(uri, "bulk_delete") self.assertEqual(rv.status_code, 200) response = json.loads(rv.data.decode("utf-8")) expected_response = {"message": f"Deleted {dashboard_count} dashboards"} @@ -468,7 +478,7 @@ def test_create_dashboard(self): } self.login(username="admin") uri = "api/v1/dashboard/" - rv = self.client.post(uri, json=dashboard_data) + rv = self.post_assert_metric(uri, dashboard_data, "post") self.assertEqual(rv.status_code, 201) data = json.loads(rv.data.decode("utf-8")) model = db.session.query(Dashboard).get(data.get("id")) @@ -520,7 +530,7 @@ def test_create_dashboard_validate_title(self): dashboard_data = {"dashboard_title": "a" * 600} self.login(username="admin") uri = "api/v1/dashboard/" - rv = self.client.post(uri, json=dashboard_data) + rv = self.post_assert_metric(uri, dashboard_data, "post") self.assertEqual(rv.status_code, 400) response = json.loads(rv.data.decode("utf-8")) expected_response = { @@ -603,7 +613,7 @@ def test_update_dashboard(self): dashboard_id = self.insert_dashboard("title1", "slug1", [admin.id]).id self.login(username="admin") uri = f"api/v1/dashboard/{dashboard_id}" - rv = self.client.put(uri, json=self.dashboard_data) + rv = self.put_assert_metric(uri, self.dashboard_data, "put") self.assertEqual(rv.status_code, 200) model = db.session.query(Dashboard).get(dashboard_id) self.assertEqual(model.dashboard_title, self.dashboard_data["dashboard_title"]) @@ -801,7 +811,7 @@ def test_update_dashboard_not_owned(self): self.login(username="alpha2", password="password") dashboard_data = {"dashboard_title": "title1_changed", "slug": "slug1 changed"} uri = f"api/v1/dashboard/{dashboard.id}" - rv = self.client.put(uri, json=dashboard_data) + rv = self.put_assert_metric(uri, dashboard_data, "put") self.assertEqual(rv.status_code, 403) db.session.delete(dashboard) db.session.delete(user_alpha1) @@ -815,8 +825,7 @@ def test_export(self): self.login(username="admin") argument = [1, 2] uri = f"api/v1/dashboard/export/?q={prison.dumps(argument)}" - - rv = self.client.get(uri) + rv = self.get_assert_metric(uri, "export") self.assertEqual(rv.status_code, 200) self.assertEqual( rv.headers["Content-Disposition"], From 5e279086a670eed3f44d92f69877cce662f9039f Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 15 Apr 2020 13:49:01 +0100 Subject: [PATCH 04/10] [dashboards] DRYing --- superset/views/base_api.py | 40 ++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 7b093c1b2823..ee6af012c2bd 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -17,7 +17,7 @@ import functools import logging from timeit import default_timer -from typing import Any, cast, Dict, Optional, Set, Tuple, Type, Union +from typing import Any, cast, Callable, Dict, Optional, Set, Tuple, Type, Union from flask import Response from flask_appbuilder import ModelRestApi @@ -36,16 +36,28 @@ } +def time_function(func: Callable, *args, **kwargs) -> Tuple[float, Any]: + """ + Measures the amount of time a function takes to execute in ms + :param func: The function execution time to measure + :param args: args to be passed to the function + :param kwargs: kwargs to be passed to the function + :return: A tuple with the duration and response from the function + """ + start = default_timer() + response = func(*args, **kwargs) + stop = default_timer() + return stop - start, response + + def statsd_metrics(f): """ Handle sending all statsd metrics from the REST API """ def wraps(self, *args: Any, **kwargs: Any) -> Response: - start = default_timer() - response = f(self, *args, **kwargs) - stop = default_timer() - self.send_stats_metrics(response, f.__name__, stop - start) + duration, response = time_function(f, self, *args, **kwargs) + self.send_stats_metrics(response, f.__name__, duration) return response return functools.update_wrapper(wraps, f) @@ -181,30 +193,24 @@ def info_headless(self, **kwargs) -> Response: """ Add statsd metrics to builtin FAB _info endpoint """ - start = default_timer() - response = super().info_headless(**kwargs) - stop = default_timer() - self.send_stats_metrics(response, self.info.__name__, stop - start) + duration, response = time_function(super().info_headless, **kwargs) + self.send_stats_metrics(response, self.info.__name__, duration) return response def get_headless(self, pk, **kwargs) -> Response: """ Add statsd metrics to builtin FAB GET endpoint """ - start = default_timer() - response = super().get_headless(pk, **kwargs) - stop = default_timer() - self.send_stats_metrics(response, self.get.__name__, stop - start) + duration, response = time_function(super().get_headless, pk, **kwargs) + self.send_stats_metrics(response, self.get.__name__, duration) return response def get_list_headless(self, **kwargs) -> Response: """ Add statsd metrics to builtin FAB GET list endpoint """ - start = default_timer() - response = super().get_list_headless(**kwargs) - stop = default_timer() - self.send_stats_metrics(response, self.get_list.__name__, stop - start) + duration, response = time_function(super().get_list_headless, **kwargs) + self.send_stats_metrics(response, self.get_list.__name__, duration) return response @expose("/related/", methods=["GET"]) From f73547ef7e19974aebe6bcf74688fe4caa13a570 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 15 Apr 2020 13:53:18 +0100 Subject: [PATCH 05/10] [dashboards] lint --- superset/views/base_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/views/base_api.py b/superset/views/base_api.py index ee6af012c2bd..a73d159913ac 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -17,7 +17,7 @@ import functools import logging from timeit import default_timer -from typing import Any, cast, Callable, Dict, Optional, Set, Tuple, Type, Union +from typing import Any, Callable, cast, Dict, Optional, Set, Tuple, Type, Union from flask import Response from flask_appbuilder import ModelRestApi From 6e79be101375d2f38ec996fda53c207d15c362f8 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 15 Apr 2020 14:24:07 +0100 Subject: [PATCH 06/10] wakey wakey GitHub Actions From 735a3a737b2243985cc86e5689e222546c4938bc Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 15 Apr 2020 14:49:38 +0100 Subject: [PATCH 07/10] [dashboards] lint --- superset/utils/core.py | 17 +++++++++++++++++ superset/views/base_api.py | 19 +++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/superset/utils/core.py b/superset/utils/core.py index e72c8ccabd8b..a93924dcb15c 100644 --- a/superset/utils/core.py +++ b/superset/utils/core.py @@ -39,6 +39,7 @@ from time import struct_time from typing import ( Any, + Callable, Dict, Iterator, List, @@ -49,6 +50,7 @@ TYPE_CHECKING, Union, ) +from timeit import default_timer from urllib.parse import unquote_plus import bleach @@ -1225,6 +1227,21 @@ def create_ssl_cert_file(certificate: str) -> str: return path +def time_function(func: Callable, *args, **kwargs) -> Tuple[float, Any]: + """ + Measures the amount of time a function takes to execute in ms + + :param func: The function execution time to measure + :param args: args to be passed to the function + :param kwargs: kwargs to be passed to the function + :return: A tuple with the duration and response from the function + """ + start = default_timer() + response = func(*args, **kwargs) + stop = default_timer() + return stop - start, response + + def MediumText() -> Variant: return Text().with_variant(MEDIUMTEXT(), "mysql") diff --git a/superset/views/base_api.py b/superset/views/base_api.py index a73d159913ac..eb08c3ad456c 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -16,8 +16,7 @@ # under the License. import functools import logging -from timeit import default_timer -from typing import Any, Callable, cast, Dict, Optional, Set, Tuple, Type, Union +from typing import Any, cast, Dict, Optional, Set, Tuple, Type, Union from flask import Response from flask_appbuilder import ModelRestApi @@ -25,6 +24,8 @@ from flask_appbuilder.models.filters import BaseFilter, Filters from flask_appbuilder.models.sqla.filters import FilterStartsWith +from superset.utils.core import time_function + logger = logging.getLogger(__name__) get_related_schema = { "type": "object", @@ -36,20 +37,6 @@ } -def time_function(func: Callable, *args, **kwargs) -> Tuple[float, Any]: - """ - Measures the amount of time a function takes to execute in ms - :param func: The function execution time to measure - :param args: args to be passed to the function - :param kwargs: kwargs to be passed to the function - :return: A tuple with the duration and response from the function - """ - start = default_timer() - response = func(*args, **kwargs) - stop = default_timer() - return stop - start, response - - def statsd_metrics(f): """ Handle sending all statsd metrics from the REST API From ba1c59e25ffe61ace3e701dc8e1e26b2bcd035dc Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 15 Apr 2020 17:22:35 +0100 Subject: [PATCH 08/10] [tests] Fix docs --- superset/views/base_api.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 70f27532798c..3f426178d697 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -40,7 +40,7 @@ def statsd_metrics(f): """ - Handle sending all statsd metrics from the REST API + Handle sending all statsd metrics from the REST API """ def wraps(self, *args: Any, **kwargs: Any) -> Response: @@ -145,7 +145,8 @@ def _get_related_filter(self, datamodel, column_name: str, value: str) -> Filter def incr_stats(self, action: str, func_name: str) -> None: """ - Proxy function for statsd.incr to impose a key structure for REST API's + Proxy function for statsd.incr to impose a key structure for REST API's + :param action: String with an action name eg: error, success :param func_name: The function name """ @@ -153,7 +154,8 @@ def incr_stats(self, action: str, func_name: str) -> None: def timing_stats(self, action: str, func_name: str, value: float) -> None: """ - Proxy function for statsd.incr to impose a key structure for REST API's + Proxy function for statsd.incr to impose a key structure for REST API's + :param action: String with an action name eg: error, success :param func_name: The function name :param value: A float with the time it took for the endpoint to execute @@ -166,7 +168,8 @@ def send_stats_metrics( self, response: Response, key: str, time_delta: Optional[float] = None ) -> None: """ - Helper function to handle sending statsd metrics + Helper function to handle sending statsd metrics + :param response: flask response object, will evaluate if it was an error :param key: The function name :param time_delta: Optional time it took for the endpoint to execute @@ -180,7 +183,7 @@ def send_stats_metrics( def info_headless(self, **kwargs) -> Response: """ - Add statsd metrics to builtin FAB _info endpoint + Add statsd metrics to builtin FAB _info endpoint """ duration, response = time_function(super().info_headless, **kwargs) self.send_stats_metrics(response, self.info.__name__, duration) @@ -188,7 +191,7 @@ def info_headless(self, **kwargs) -> Response: def get_headless(self, pk, **kwargs) -> Response: """ - Add statsd metrics to builtin FAB GET endpoint + Add statsd metrics to builtin FAB GET endpoint """ duration, response = time_function(super().get_headless, pk, **kwargs) self.send_stats_metrics(response, self.get.__name__, duration) @@ -196,7 +199,7 @@ def get_headless(self, pk, **kwargs) -> Response: def get_list_headless(self, **kwargs) -> Response: """ - Add statsd metrics to builtin FAB GET list endpoint + Add statsd metrics to builtin FAB GET list endpoint """ duration, response = time_function(super().get_list_headless, **kwargs) self.send_stats_metrics(response, self.get_list.__name__, duration) From f2170ac02461baf52658dfc5a7b483fa5c64777f Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 15 Apr 2020 17:24:19 +0100 Subject: [PATCH 09/10] [tests] Fix docs --- tests/base_tests.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/base_tests.py b/tests/base_tests.py index cec3d66b4967..370adf306775 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -333,7 +333,8 @@ def get_dash_by_slug(self, dash_slug): def get_assert_metric(self, uri: str, func_name: str) -> Response: """ - Simple client get with an extra assertion for statsd metrics + Simple client get with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP GET :param func_name: The function name that the HTTP GET triggers for the statsd metric assertion @@ -351,7 +352,8 @@ def get_assert_metric(self, uri: str, func_name: str) -> Response: def delete_assert_metric(self, uri: str, func_name: str) -> Response: """ - Simple client delete with an extra assertion for statsd metrics + Simple client delete with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP DELETE :param func_name: The function name that the HTTP DELETE triggers for the statsd metric assertion @@ -369,7 +371,8 @@ def delete_assert_metric(self, uri: str, func_name: str) -> Response: def post_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response: """ - Simple client post with an extra assertion for statsd metrics + Simple client post with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP POST :param data: The JSON data payload to be posted :param func_name: The function name that the HTTP POST triggers @@ -388,7 +391,8 @@ def post_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response: def put_assert_metric(self, uri: str, data: Dict, func_name: str) -> Response: """ - Simple client put with an extra assertion for statsd metrics + Simple client put with an extra assertion for statsd metrics + :param uri: The URI to use for the HTTP PUT :param data: The JSON data payload to be posted :param func_name: The function name that the HTTP PUT triggers From 18f1eb2da5068cf8fcc842920be0f5a2a4cf3547 Mon Sep 17 00:00:00 2001 From: Daniel Gaspar Date: Wed, 15 Apr 2020 17:30:31 +0100 Subject: [PATCH 10/10] [tests] Fix docs --- tests/dashboards/api_tests.py | 68 +++++++++++++++++------------------ 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/tests/dashboards/api_tests.py b/tests/dashboards/api_tests.py index 9e205f4ce117..f8119f55592d 100644 --- a/tests/dashboards/api_tests.py +++ b/tests/dashboards/api_tests.py @@ -79,7 +79,7 @@ def insert_dashboard( def test_get_dashboard(self): """ - Dashboard API: Test get dashboard + Dashboard API: Test get dashboard """ admin = self.get_user("admin") dashboard = self.insert_dashboard("title", "slug1", [admin.id]) @@ -123,7 +123,7 @@ def test_get_dashboard(self): def test_info_dashboard(self): """ - Dashboard API: Test info + Dashboard API: Test info """ self.login(username="admin") uri = f"api/v1/dashboard/_info" @@ -132,7 +132,7 @@ def test_info_dashboard(self): def test_get_dashboard_not_found(self): """ - Dashboard API: Test get dashboard not found + Dashboard API: Test get dashboard not found """ max_id = db.session.query(func.max(Dashboard.id)).scalar() self.login(username="admin") @@ -142,7 +142,7 @@ def test_get_dashboard_not_found(self): def test_get_dashboard_no_data_access(self): """ - Dashboard API: Test get dashboard without data access + Dashboard API: Test get dashboard without data access """ admin = self.get_user("admin") dashboard = self.insert_dashboard("title", "slug1", [admin.id]) @@ -157,7 +157,7 @@ def test_get_dashboard_no_data_access(self): def test_get_dashboards_filter(self): """ - Dashboard API: Test get dashboards filter + Dashboard API: Test get dashboards filter """ admin = self.get_user("admin") gamma = self.get_user("gamma") @@ -192,7 +192,7 @@ def test_get_dashboards_filter(self): def test_get_dashboards_custom_filter(self): """ - Dashboard API: Test get dashboards custom filter + Dashboard API: Test get dashboards custom filter """ admin = self.get_user("admin") dashboard1 = self.insert_dashboard("foo", "ZY_bar", [admin.id]) @@ -242,7 +242,7 @@ def test_get_dashboards_custom_filter(self): def test_get_dashboards_no_data_access(self): """ - Dashboard API: Test get dashboards no data access + Dashboard API: Test get dashboards no data access """ admin = self.get_user("admin") dashboard = self.insert_dashboard("title", "slug1", [admin.id]) @@ -263,7 +263,7 @@ def test_get_dashboards_no_data_access(self): def test_delete_dashboard(self): """ - Dashboard API: Test delete + Dashboard API: Test delete """ admin_id = self.get_user("admin").id dashboard_id = self.insert_dashboard("title", "slug1", [admin_id]).id @@ -276,7 +276,7 @@ def test_delete_dashboard(self): def test_delete_bulk_dashboards(self): """ - Dashboard API: Test delete bulk + Dashboard API: Test delete bulk """ admin_id = self.get_user("admin").id dashboard_count = 4 @@ -303,7 +303,7 @@ def test_delete_bulk_dashboards(self): def test_delete_bulk_dashboards_bad_request(self): """ - Dashboard API: Test delete bulk bad request + Dashboard API: Test delete bulk bad request """ dashboard_ids = [1, "a"] self.login(username="admin") @@ -314,7 +314,7 @@ def test_delete_bulk_dashboards_bad_request(self): def test_delete_not_found_dashboard(self): """ - Dashboard API: Test not found delete + Dashboard API: Test not found delete """ self.login(username="admin") dashboard_id = 1000 @@ -324,7 +324,7 @@ def test_delete_not_found_dashboard(self): def test_delete_bulk_dashboards_not_found(self): """ - Dashboard API: Test delete bulk not found + Dashboard API: Test delete bulk not found """ dashboard_ids = [1001, 1002] self.login(username="admin") @@ -335,7 +335,7 @@ def test_delete_bulk_dashboards_not_found(self): def test_delete_dashboard_admin_not_owned(self): """ - Dashboard API: Test admin delete not owned + Dashboard API: Test admin delete not owned """ gamma_id = self.get_user("gamma").id dashboard_id = self.insert_dashboard("title", "slug1", [gamma_id]).id @@ -349,7 +349,7 @@ def test_delete_dashboard_admin_not_owned(self): def test_delete_bulk_dashboard_admin_not_owned(self): """ - Dashboard API: Test admin delete bulk not owned + Dashboard API: Test admin delete bulk not owned """ gamma_id = self.get_user("gamma").id dashboard_count = 4 @@ -378,7 +378,7 @@ def test_delete_bulk_dashboard_admin_not_owned(self): def test_delete_dashboard_not_owned(self): """ - Dashboard API: Test delete try not owned + Dashboard API: Test delete try not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -403,7 +403,7 @@ def test_delete_dashboard_not_owned(self): def test_delete_bulk_dashboard_not_owned(self): """ - Dashboard API: Test delete bulk try not owned + Dashboard API: Test delete bulk try not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -465,7 +465,7 @@ def test_delete_bulk_dashboard_not_owned(self): def test_create_dashboard(self): """ - Dashboard API: Test create dashboard + Dashboard API: Test create dashboard """ admin_id = self.get_user("admin").id dashboard_data = { @@ -488,7 +488,7 @@ def test_create_dashboard(self): def test_create_simple_dashboard(self): """ - Dashboard API: Test create simple dashboard + Dashboard API: Test create simple dashboard """ dashboard_data = {"dashboard_title": "title1"} self.login(username="admin") @@ -502,7 +502,7 @@ def test_create_simple_dashboard(self): def test_create_dashboard_empty(self): """ - Dashboard API: Test create empty + Dashboard API: Test create empty """ dashboard_data = {} self.login(username="admin") @@ -526,7 +526,7 @@ def test_create_dashboard_empty(self): def test_create_dashboard_validate_title(self): """ - Dashboard API: Test create dashboard validate title + Dashboard API: Test create dashboard validate title """ dashboard_data = {"dashboard_title": "a" * 600} self.login(username="admin") @@ -541,7 +541,7 @@ def test_create_dashboard_validate_title(self): def test_create_dashboard_validate_slug(self): """ - Dashboard API: Test create validate slug + Dashboard API: Test create validate slug """ admin_id = self.get_user("admin").id dashboard = self.insert_dashboard("title1", "slug1", [admin_id]) @@ -570,7 +570,7 @@ def test_create_dashboard_validate_slug(self): def test_create_dashboard_validate_owners(self): """ - Dashboard API: Test create validate owners + Dashboard API: Test create validate owners """ dashboard_data = {"dashboard_title": "title1", "owners": [1000]} self.login(username="admin") @@ -583,7 +583,7 @@ def test_create_dashboard_validate_owners(self): def test_create_dashboard_validate_json(self): """ - Dashboard API: Test create validate json + Dashboard API: Test create validate json """ dashboard_data = {"dashboard_title": "title1", "position_json": '{"A:"a"}'} self.login(username="admin") @@ -608,7 +608,7 @@ def test_create_dashboard_validate_json(self): def test_update_dashboard(self): """ - Dashboard API: Test update + Dashboard API: Test update """ admin = self.get_user("admin") dashboard_id = self.insert_dashboard("title1", "slug1", [admin.id]).id @@ -630,7 +630,7 @@ def test_update_dashboard(self): def test_update_dashboard_chart_owners(self): """ - Dashboard API: Test update chart owners + Dashboard API: Test update chart owners """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -673,7 +673,7 @@ def test_update_dashboard_chart_owners(self): def test_update_partial_dashboard(self): """ - Dashboard API: Test update partial + Dashboard API: Test update partial """ admin_id = self.get_user("admin").id dashboard_id = self.insert_dashboard("title1", "slug1", [admin_id]).id @@ -702,7 +702,7 @@ def test_update_partial_dashboard(self): def test_update_dashboard_new_owner(self): """ - Dashboard API: Test update set new owner to current user + Dashboard API: Test update set new owner to current user """ gamma_id = self.get_user("gamma").id admin = self.get_user("admin") @@ -721,7 +721,7 @@ def test_update_dashboard_new_owner(self): def test_update_dashboard_slug_formatting(self): """ - Dashboard API: Test update slug formatting + Dashboard API: Test update slug formatting """ admin_id = self.get_user("admin").id dashboard_id = self.insert_dashboard("title1", "slug1", [admin_id]).id @@ -738,7 +738,7 @@ def test_update_dashboard_slug_formatting(self): def test_update_dashboard_validate_slug(self): """ - Dashboard API: Test update validate slug + Dashboard API: Test update validate slug """ admin_id = self.get_user("admin").id dashboard1 = self.insert_dashboard("title1", "slug-1", [admin_id]) @@ -773,7 +773,7 @@ def test_update_dashboard_validate_slug(self): def test_update_published(self): """ - Dashboard API: Test update published patch + Dashboard API: Test update published patch """ admin = self.get_user("admin") gamma = self.get_user("gamma") @@ -795,7 +795,7 @@ def test_update_published(self): def test_update_dashboard_not_owned(self): """ - Dashboard API: Test update dashboard not owned + Dashboard API: Test update dashboard not owned """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -821,7 +821,7 @@ def test_update_dashboard_not_owned(self): def test_export(self): """ - Dashboard API: Test dashboard export + Dashboard API: Test dashboard export """ self.login(username="admin") argument = [1, 2] @@ -835,7 +835,7 @@ def test_export(self): def test_export_not_found(self): """ - Dashboard API: Test dashboard export not found + Dashboard API: Test dashboard export not found """ self.login(username="admin") argument = [1000] @@ -845,7 +845,7 @@ def test_export_not_found(self): def test_export_not_allowed(self): """ - Dashboard API: Test dashboard export not allowed + Dashboard API: Test dashboard export not allowed """ admin_id = self.get_user("admin").id dashboard = self.insert_dashboard("title", "slug1", [admin_id], published=False)