diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 76787117fd61..0454b288c8e7 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -58,6 +58,7 @@ FilterRelatedRoles, ) from superset.dashboards.schemas import ( + DashboardGetResponseSchema, DashboardPostSchema, DashboardPutSchema, get_delete_ids_schema, @@ -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", @@ -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: []]] @@ -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, @@ -222,6 +205,53 @@ def __init__(self) -> None: self.include_route_methods = self.include_route_methods | {"thumbnail"} super().__init__() + @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_slug: str) -> Response: + """Gets a dashboard + --- + get: + description: >- + Get a dashboard + parameters: + - in: path + schema: + type: string + name: id_or_slug + description: Either the id of the dashboard, or its slug + responses: + 200: + description: Dashboard + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/DashboardGetResponseSchema' + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + """ + # pylint: disable=arguments-differ + try: + dash = DashboardDAO.get_by_id_or_slug(id_or_slug) + result = self.dashboard_get_response_schema.dump(dash) + return self.response(200, result=result) + except DashboardNotFoundError: + return self.response_404() + @expose("//charts", methods=["GET"]) @protect() @safe diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py index 142331efad1c..33ec15f91716 100644 --- a/superset/dashboards/dao.py +++ b/superset/dashboards/dao.py @@ -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 @@ -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: + raise DashboardNotFoundError() + return dashboard + @staticmethod def get_charts_for_dashboard(dashboard_id: int) -> List[Slice]: query = ( diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 360f94f74d94..eae7598c1821 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -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."}}, @@ -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 diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index f827cd42c8e3..b73027d681e1 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -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 @@ -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__) @@ -361,15 +364,16 @@ def export_dashboards( # pylint: disable=too-many-locals @classmethod def get(cls, id_or_slug: str) -> Dashboard: session = db.session() - 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 diff --git a/tests/dashboards/api_tests.py b/tests/dashboards/api_tests.py index a9b0ca4812f4..bd4ab37d7d19 100644 --- a/tests/dashboards/api_tests.py +++ b/tests/dashboards/api_tests.py @@ -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): """ @@ -241,6 +267,7 @@ def test_get_dashboard(self): "id": dashboard.id, "css": "", "dashboard_title": "title", + "datasources": [], "json_metadata": "", "owners": [ {