feat(dashboard): API to get a dashboard's charts (#12978)

* feat(dashboard): get endpoint for a dashboard's charts

* temporary debugging fetch on the frontend

* attempted fixes

* singular -> plural derp

* plural -> singular derp derp

* docstring changes

* change return, no id

* move log above query

* add get_charts to include_route_methods /)_-)

* add get charts api

* result not response

* refactor test helper function to a mixin

* add test for new endpoint

* fix test when running in isolation

* correct comment

* rename test

* more tests, handle dashboard not found

* simplify test to use new helper function

* remove debugging code from frontend

* update docstring

* attempt a doc fix

* add id to api docs

* fix docs

* use pytest fixture

* why oh why does test order matter here, idk

* writing a schema for the endpoint

* more efficient fetching of charts

* testing tweaks

Co-authored-by: Phillip Kelley-Dotson <pkelleydotson@yahoo.com>
This commit is contained in:
David Aaron Suddjian
2021-02-15 11:41:59 -08:00
committed by GitHub
parent 2e6ea76631
commit cc9103b0e2
8 changed files with 228 additions and 49 deletions

View File

@@ -91,6 +91,13 @@ datasource_type_description = (
)
datasource_name_description = "The datasource name."
dashboards_description = "A list of dashboards to include this new chart to."
changed_on_description = "The ISO date that the chart was last changed."
slice_url_description = "The URL of the chart."
form_data_description = (
"Form data from the Explore controls used to form the chart's data query."
)
description_markeddown_description = "Sanitized HTML version of the chart description."
owners_name_description = "Name of an owner of the chart."
#
# OpenAPI method specification overrides
@@ -138,6 +145,24 @@ TIME_GRAINS = (
)
class ChartEntityResponseSchema(Schema):
"""
Schema for a chart object
"""
slice_id = fields.Integer()
slice_name = fields.String(description=slice_name_description)
cache_timeout = fields.Integer(description=cache_timeout_description)
changed_on = fields.String(description=changed_on_description)
datasource = fields.String(description=datasource_name_description)
description = fields.String(description=description_description)
description_markeddown = fields.String(
description=description_markeddown_description
)
form_data = fields.Dict(description=form_data_description)
slice_url = fields.String(description=slice_url_description)
class ChartPostSchema(Schema):
"""
Schema to add a new chart.
@@ -1175,6 +1200,7 @@ CHART_SCHEMAS = (
ChartDataGeohashDecodeOptionsSchema,
ChartDataGeohashEncodeOptionsSchema,
ChartDataGeodeticParseOptionsSchema,
ChartEntityResponseSchema,
ChartGetDatasourceResponseSchema,
ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,

View File

@@ -114,4 +114,5 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"screenshot": "read",
"data": "read",
"data_from_cache": "read",
"get_charts": "read",
}

View File

@@ -30,6 +30,7 @@ from werkzeug.wrappers import Response as WerkzeugResponse
from werkzeug.wsgi import FileWrapper
from superset import is_feature_enabled, thumbnail_cache
from superset.charts.schemas import ChartEntityResponseSchema
from superset.commands.exceptions import CommandInvalidError
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
@@ -90,6 +91,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
"favorite_status",
"get_charts",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -187,6 +189,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
add_model_schema = DashboardPostSchema()
edit_model_schema = DashboardPutSchema()
chart_entity_response_schema = ChartEntityResponseSchema()
base_filters = [["slice", DashboardFilter, lambda: []]]
@@ -204,7 +207,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
openapi_spec_tag = "Dashboards"
""" Override the name set for this collection of endpoints """
openapi_spec_component_schemas = (GetFavStarIdsSchema,)
openapi_spec_component_schemas = (ChartEntityResponseSchema, GetFavStarIdsSchema)
apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema,
"get_export_ids_schema": get_export_ids_schema,
@@ -219,6 +222,53 @@ class DashboardRestApi(BaseSupersetModelRestApi):
self.include_route_methods = self.include_route_methods | {"thumbnail"}
super().__init__()
@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

View File

@@ -19,8 +19,10 @@ import logging
from typing import Any, Dict, List, Optional
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import contains_eager
from superset.dao.base import BaseDAO
from superset.dashboards.commands.exceptions import DashboardNotFoundError
from superset.dashboards.filters import DashboardFilter
from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
@@ -35,6 +37,20 @@ class DashboardDAO(BaseDAO):
model_cls = Dashboard
base_filter = DashboardFilter
@staticmethod
def get_charts_for_dashboard(dashboard_id: int) -> List[Slice]:
query = (
db.session.query(Dashboard)
.outerjoin(Slice, Dashboard.slices)
.outerjoin(Slice.table)
.filter(Dashboard.id == dashboard_id)
.options(contains_eager(Dashboard.slices))
)
dashboard = query.one_or_none()
if not dashboard:
raise DashboardNotFoundError()
return dashboard.slices
@staticmethod
def validate_slug_uniqueness(slug: str) -> bool:
if not slug:

View File

@@ -29,6 +29,7 @@ from flask_appbuilder.security.sqla import models as ab_models
from flask_testing import TestCase
from sqlalchemy.ext.declarative.api import DeclarativeMeta
from sqlalchemy.orm import Session
from sqlalchemy.sql import func
from tests.test_app import app
from superset.sql_parse import CtasMethod
@@ -124,6 +125,10 @@ class SupersetTestCase(TestCase):
def create_app(self):
return app
@staticmethod
def get_nonexistent_numeric_id(model):
return (db.session.query(func.max(model.id)).scalar() or 0) + 1
@staticmethod
def get_birth_names_dataset():
example_db = get_example_database()

View File

@@ -17,13 +17,13 @@
# isort:skip_file
"""Unit tests for Superset"""
import json
from typing import List, Optional
from datetime import datetime, timedelta
from io import BytesIO
from unittest import mock
from zipfile import is_zipfile, ZipFile
from superset.models.sql_lab import Query
from tests.insert_chart_mixin import InsertChartMixin
from tests.fixtures.birth_names_dashboard import load_birth_names_dashboard_with_slices
import humanize
@@ -36,9 +36,8 @@ from sqlalchemy.sql import func
from tests.fixtures.world_bank_dashboard import load_world_bank_dashboard_with_slices
from tests.test_app import app
from superset.charts.commands.data import ChartDataCommand
from superset.connectors.connector_registry import ConnectorRegistry
from superset.connectors.sqla.models import SqlaTable
from superset.extensions import async_query_manager, cache_manager, db, security_manager
from superset.extensions import async_query_manager, cache_manager, db
from superset.models.annotations import AnnotationLayer
from superset.models.core import Database, FavStar, FavStarClassName
from superset.models.dashboard import Dashboard
@@ -67,44 +66,9 @@ CHART_DATA_URI = "api/v1/chart/data"
CHARTS_FIXTURE_COUNT = 10
class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin):
class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixin):
resource_name = "chart"
def insert_chart(
self,
slice_name: str,
owners: List[int],
datasource_id: int,
created_by=None,
datasource_type: str = "table",
description: Optional[str] = None,
viz_type: Optional[str] = None,
params: Optional[str] = None,
cache_timeout: Optional[int] = None,
) -> Slice:
obj_owners = list()
for owner in owners:
user = db.session.query(security_manager.user_model).get(owner)
obj_owners.append(user)
datasource = ConnectorRegistry.get_datasource(
datasource_type, datasource_id, db.session
)
slice = Slice(
cache_timeout=cache_timeout,
created_by=created_by,
datasource_id=datasource.id,
datasource_name=datasource.name,
datasource_type=datasource.type,
description=description,
owners=obj_owners,
params=params,
slice_name=slice_name,
viz_type=viz_type,
)
db.session.add(slice)
db.session.commit()
return slice
@pytest.fixture(autouse=True)
def clear_data_cache(self):
with app.app_context():

View File

@@ -23,6 +23,8 @@ from typing import List, Optional
from unittest.mock import patch
from zipfile import is_zipfile, ZipFile
from tests.insert_chart_mixin import InsertChartMixin
import pytest
import prison
import yaml
@@ -54,9 +56,10 @@ from tests.fixtures.world_bank_dashboard import load_world_bank_dashboard_with_s
DASHBOARDS_FIXTURE_COUNT = 10
class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixin):
resource_name = "dashboard"
dashboards: List[Dashboard] = []
dashboard_data = {
"dashboard_title": "title1_changed",
"slug": "slug1_changed",
@@ -109,21 +112,35 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
with self.create_app().app_context():
dashboards = []
admin = self.get_user("admin")
for cx in range(DASHBOARDS_FIXTURE_COUNT - 1):
dashboards.append(
self.insert_dashboard(f"title{cx}", f"slug{cx}", [admin.id])
charts = []
half_dash_count = round(DASHBOARDS_FIXTURE_COUNT / 2)
for cx in range(DASHBOARDS_FIXTURE_COUNT):
dashboard = self.insert_dashboard(
f"title{cx}",
f"slug{cx}",
[admin.id],
slices=charts if cx < half_dash_count else [],
)
if cx < half_dash_count:
chart = self.insert_chart(f"slice{cx}", [admin.id], 1, params="{}")
charts.append(chart)
dashboard.slices = [chart]
db.session.add(dashboard)
dashboards.append(dashboard)
fav_dashboards = []
for cx in range(round(DASHBOARDS_FIXTURE_COUNT / 2)):
for cx in range(half_dash_count):
fav_star = FavStar(
user_id=admin.id, class_name="Dashboard", obj_id=dashboards[cx].id
)
db.session.add(fav_star)
db.session.commit()
fav_dashboards.append(fav_star)
self.dashboards = dashboards
yield dashboards
# rollback changes
for chart in charts:
db.session.delete(chart)
for dashboard in dashboards:
db.session.delete(dashboard)
for fav_dashboard in fav_dashboards:
@@ -152,6 +169,45 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
db.session.delete(dashboard)
db.session.commit()
@pytest.mark.usefixtures("create_dashboards")
def test_get_dashboard_charts(self):
"""
Dashboard API: Test getting charts belonging to a dashboard
"""
dashboard = self.dashboards[0]
uri = f"api/v1/dashboard/{dashboard.id}/charts"
response = self.get_assert_metric(uri, "get_charts")
self.assertEqual(response.status_code, 200)
data = json.loads(response.data.decode("utf-8"))
self.assertEqual(len(data["result"]), 1)
self.assertEqual(
data["result"][0]["slice_name"], dashboard.slices[0].slice_name
)
@pytest.mark.usefixtures("create_dashboards")
def test_get_dashboard_charts_not_found(self):
"""
Dashboard API: Test getting charts belonging to a dashboard that does not exist
"""
self.login(username="admin")
bad_id = self.get_nonexistent_numeric_id(Dashboard)
uri = f"api/v1/dashboard/{bad_id}/charts"
response = self.get_assert_metric(uri, "get_charts")
self.assertEqual(response.status_code, 404)
@pytest.mark.usefixtures("create_dashboards")
def test_get_dashboard_charts_empty(self):
"""
Dashboard API: Test getting charts belonging to a dashboard without any charts
"""
self.login(username="admin")
# the fixture setup assigns no charts to the second half of dashboards
uri = f"api/v1/dashboard/{self.dashboards[-1].id}/charts"
response = self.get_assert_metric(uri, "get_charts")
self.assertEqual(response.status_code, 200)
data = json.loads(response.data.decode("utf-8"))
self.assertEqual(data["result"], [])
def test_get_dashboard(self):
"""
Dashboard API: Test get dashboard
@@ -228,9 +284,9 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
"""
Dashboard API: Test get dashboard not found
"""
max_id = db.session.query(func.max(Dashboard.id)).scalar()
bad_id = self.get_nonexistent_numeric_id(Dashboard)
self.login(username="admin")
uri = f"api/v1/dashboard/{max_id + 1}"
uri = f"api/v1/dashboard/{bad_id}"
rv = self.get_assert_metric(uri, "get")
self.assertEqual(rv.status_code, 404)
@@ -575,7 +631,7 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
self.assertEqual(rv.status_code, 404)
@pytest.mark.usefixtures("create_dashboard_with_report", "create_dashboards")
def test_bulk_delete_dashboard_with_report(self):
def test_delete_bulk_dashboard_with_report(self):
"""
Dashboard API: Test bulk delete with associated report
"""
@@ -1308,7 +1364,7 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
def test_import_dashboard_invalid(self):
"""
Dataset API: Test import invalid dashboard
Dashboard API: Test import invalid dashboard
"""
self.login(username="admin")
uri = "api/v1/dashboard/import/"

View File

@@ -0,0 +1,61 @@
# 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.
from typing import List, Optional
from superset import ConnectorRegistry, db, security_manager
from superset.models.slice import Slice
class InsertChartMixin:
"""
Implements shared logic for tests to insert charts (slices) in the DB
"""
def insert_chart(
self,
slice_name: str,
owners: List[int],
datasource_id: int,
created_by=None,
datasource_type: str = "table",
description: Optional[str] = None,
viz_type: Optional[str] = None,
params: Optional[str] = None,
cache_timeout: Optional[int] = None,
) -> Slice:
obj_owners = list()
for owner in owners:
user = db.session.query(security_manager.user_model).get(owner)
obj_owners.append(user)
datasource = ConnectorRegistry.get_datasource(
datasource_type, datasource_id, db.session
)
slice = Slice(
cache_timeout=cache_timeout,
created_by=created_by,
datasource_id=datasource.id,
datasource_name=datasource.name,
datasource_type=datasource.type,
description=description,
owners=obj_owners,
params=params,
slice_name=slice_name,
viz_type=viz_type,
)
db.session.add(slice)
db.session.commit()
return slice