Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 54 additions & 24 deletions superset/dashboards/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
FilterRelatedRoles,
)
from superset.dashboards.schemas import (
DashboardGetResponseSchema,
DashboardPostSchema,
DashboardPutSchema,
get_delete_ids_schema,
Expand Down Expand Up @@ -99,29 +100,6 @@ class DashboardRestApi(BaseSupersetModelRestApi):
class_permission_name = "Dashboard"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP

show_columns = [
"id",
"charts",
"css",
"dashboard_title",
"json_metadata",
"owners.id",
"owners.username",
"owners.first_name",
"owners.last_name",
"roles.id",
"roles.name",
"changed_by_name",
"changed_by_url",
"changed_by.username",
"changed_on",
"position_json",
"published",
"url",
"slug",
"table_names",
"thumbnail_url",
]
list_columns = [
"id",
"published",
Expand Down Expand Up @@ -190,6 +168,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
add_model_schema = DashboardPostSchema()
edit_model_schema = DashboardPutSchema()
chart_entity_response_schema = ChartEntityResponseSchema()
dashboard_get_response_schema = DashboardGetResponseSchema()

base_filters = [["slice", DashboardFilter, lambda: []]]

Expand All @@ -207,7 +186,11 @@ class DashboardRestApi(BaseSupersetModelRestApi):

openapi_spec_tag = "Dashboards"
""" Override the name set for this collection of endpoints """
openapi_spec_component_schemas = (ChartEntityResponseSchema, GetFavStarIdsSchema)
openapi_spec_component_schemas = (
ChartEntityResponseSchema,
DashboardGetResponseSchema,
GetFavStarIdsSchema,
)
apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema,
"get_export_ids_schema": get_export_ids_schema,
Expand All @@ -222,6 +205,53 @@ def __init__(self) -> None:
self.include_route_methods = self.include_route_methods | {"thumbnail"}
super().__init__()

@expose("/<id_or_slug>", methods=["GET"])
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get",
log_to_statsd=False,
)
def get(self, id_or_slug: str) -> Response:
"""Gets a dashboard
---
get:
description: >-
Get a dashboard
parameters:
- in: path
schema:
type: string
name: id_or_slug
description: Either the id of the dashboard, or its slug
responses:
200:
description: Dashboard
content:
application/json:
schema:
type: object
properties:
result:
$ref: '#/components/schemas/DashboardGetResponseSchema'
302:
description: Redirects to the current digest
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can return 500 also from the @safe decorator

"""
# pylint: disable=arguments-differ
try:
dash = DashboardDAO.get_by_id_or_slug(id_or_slug)
result = self.dashboard_get_response_schema.dump(dash)
return self.response(200, result=result)
except DashboardNotFoundError:
return self.response_404()

@expose("/<pk>/charts", methods=["GET"])
@protect()
@safe
Expand Down
21 changes: 20 additions & 1 deletion superset/dashboards/dao.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from superset.dashboards.filters import DashboardFilter
from superset.extensions import db
from superset.models.core import FavStar, FavStarClassName
from superset.models.dashboard import Dashboard
from superset.models.dashboard import Dashboard, id_or_slug_filter
from superset.models.slice import Slice
from superset.utils.dashboard_filter_scopes_converter import copy_filter_scopes

Expand All @@ -38,6 +38,25 @@ class DashboardDAO(BaseDAO):
model_cls = Dashboard
base_filter = DashboardFilter

@staticmethod
def get_by_id_or_slug(id_or_slug: str) -> Dashboard:
query = (
db.session.query(Dashboard)
.filter(id_or_slug_filter(id_or_slug))
.outerjoin(Slice, Dashboard.slices)
.outerjoin(Slice.table)
.outerjoin(Dashboard.owners)
.outerjoin(Dashboard.roles)
)
# Apply dashboard base filters
query = DashboardFilter("id", SQLAInterface(Dashboard, db.session)).apply(
query, None
)
dashboard = query.one_or_none()
if not dashboard:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this if doesn't trigger when I enter a bogus slug or id and I'm not sure why yet

raise DashboardNotFoundError()
return dashboard

@staticmethod
def get_charts_for_dashboard(dashboard_id: int) -> List[Slice]:
query = (
Expand Down
36 changes: 35 additions & 1 deletion superset/dashboards/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@
"Determines whether or not this dashboard is visible in "
"the list of all dashboards."
)

charts_description = (
"The names of the dashboard's charts. Names are used for legacy reasons."
)

openapi_spec_methods_override = {
"get": {"get": {"description": "Get a dashboard detail information."}},
Expand Down Expand Up @@ -121,6 +123,38 @@ class DashboardJSONMetadataSchema(Schema):
label_colors = fields.Dict()


class UserSchema(Schema):
id = fields.Int()
username = fields.String()
first_name = fields.String()
last_name = fields.String()


class RolesSchema(Schema):
id = fields.Int()
name = fields.String()


class DashboardGetResponseSchema(Schema):
id = fields.Int()
slug = fields.String()
url = fields.String()
dashboard_title = fields.String(description=dashboard_title_description)
thumbnail_url = fields.String()
published = fields.Boolean()
css = fields.String(description=css_description)
json_metadata = fields.String(description=json_metadata_description)
position_json = fields.String(description=position_json_description)
changed_by_name = fields.String()
changed_by_url = fields.String()
changed_by = fields.Nested(UserSchema)
changed_on = fields.DateTime()
charts = fields.List(fields.String(description=charts_description))
owners = fields.List(fields.Nested(UserSchema))
roles = fields.List(fields.Nested(RolesSchema))
table_names = fields.String() # legacy nonsense


class BaseDashboardSchema(Schema):
# pylint: disable=no-self-use,unused-argument
@post_load
Expand Down
16 changes: 10 additions & 6 deletions superset/models/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.session import object_session
from sqlalchemy.sql import join, select
from sqlalchemy.sql.elements import BinaryExpression

from superset import app, ConnectorRegistry, db, is_feature_enabled, security_manager
from superset.connectors.base.models import BaseDatasource
Expand All @@ -57,6 +58,8 @@
from superset.utils.decorators import debounce
from superset.utils.urls import get_url_path

# pylint: disable=too-many-public-methods

metadata = Model.metadata # pylint: disable=no-member
config = app.config
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -361,15 +364,16 @@ def export_dashboards( # pylint: disable=too-many-locals
@classmethod
def get(cls, id_or_slug: str) -> Dashboard:
session = db.session()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated, but seems strange that this is creating a new session here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does. I didn't feel entirely comfortable removing it though. Not sure why it was put there.

qry = session.query(Dashboard)
if id_or_slug.isdigit():
qry = qry.filter_by(id=int(id_or_slug))
else:
qry = qry.filter_by(slug=id_or_slug)

qry = session.query(Dashboard).filter(id_or_slug_filter(id_or_slug))
return qry.one_or_none()


def id_or_slug_filter(id_or_slug: str) -> BinaryExpression:
if id_or_slug.isdigit():
return Dashboard.id == int(id_or_slug)
return Dashboard.slug == id_or_slug


OnDashboardChange = Callable[[Mapper, Connection, Dashboard], Any]

# events for updating tags
Expand Down
27 changes: 27 additions & 0 deletions tests/dashboards/api_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,32 @@ def create_dashboard_with_report(self):
db.session.delete(dashboard)
db.session.commit()

@pytest.mark.usefixtures("create_dashboards")
def get_dashboard_by_slug(self):
self.login(username="admin")
dashboard = self.dashboards[0]
uri = f"api/v1/dashboard/{dashboard.slug}"
response = self.get_assert_metric(uri, "get")
self.assertEqual(response.status_code, 200)
data = json.loads(response.data.decode("utf-8"))
self.assertEqual(data["id"], dashboard.id)

@pytest.mark.usefixtures("create_dashboards")
def get_dashboard_by_bad_slug(self):
self.login(username="admin")
dashboard = self.dashboards[0]
uri = f"api/v1/dashboard/{dashboard.slug}-bad-slug"
response = self.get_assert_metric(uri, "get")
self.assertEqual(response.status_code, 404)

@pytest.mark.usefixtures("create_dashboards")
def get_dashboard_by_slug_not_allowed(self):
self.login(username="gamma")
dashboard = self.dashboards[0]
uri = f"api/v1/dashboard/{dashboard.slug}"
response = self.get_assert_metric(uri, "get")
self.assertEqual(response.status_code, 404)

@pytest.mark.usefixtures("create_dashboards")
def test_get_dashboard_charts(self):
"""
Expand Down Expand Up @@ -241,6 +267,7 @@ def test_get_dashboard(self):
"id": dashboard.id,
"css": "",
"dashboard_title": "title",
"datasources": [],
"json_metadata": "",
"owners": [
{
Expand Down