diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index 89d0ef64a..74094d6ee 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -179,6 +179,25 @@ class Dashboard( # pylint: disable=too-many-instance-attributes title = escape(self.dashboard_title or "") return Markup(f'{title}') + @property + def changed_by_name(self): + if not self.changed_by: + return "" + return str(self.changed_by) + + @property + def changed_by_url(self): + if not self.changed_by: + return "" + return f"/superset/profile/{self.changed_by.username}" + + @property + def owners_json(self) -> List[Dict[str, Any]]: + owners = [] + for owner in self.owners: + owners.append({"name": owner.name}) + return owners + @property def data(self) -> Dict[str, Any]: positions = self.position_json diff --git a/superset/views/base.py b/superset/views/base.py index 54ed0811b..15f711e86 100644 --- a/superset/views/base.py +++ b/superset/views/base.py @@ -18,14 +18,16 @@ import functools import logging import traceback from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Tuple import simplejson as json import yaml from flask import abort, flash, g, get_flashed_messages, redirect, Response, session -from flask_appbuilder import BaseView, Model, ModelView +from flask_appbuilder import BaseView, Model, ModelRestApi, ModelView from flask_appbuilder.actions import action +from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.forms import DynamicForm +from flask_appbuilder.models.filters import Filters from flask_appbuilder.models.sqla.filters import BaseFilter from flask_appbuilder.widgets import ListWidget from flask_babel import get_locale, gettext as __, lazy_gettext as _ @@ -373,6 +375,130 @@ class BaseSupersetSchema(Schema): return super().load(data, many=many, partial=partial, **kwargs) +get_related_schema = { + "type": "object", + "properties": { + "page_size": {"type": "integer"}, + "page": {"type": "integer"}, + "filter": {"type": "string"}, + }, +} + + +class BaseSupersetModelRestApi(ModelRestApi): + """ + Extends FAB's ModelResApi to implement specific superset generic functionality + """ + + order_rel_fields: Dict[str, Tuple[str, str]] = {} + """ + Impose ordering on related fields query:: + + order_rel_fields = { + "": ("", ""), + ... + } + """ # pylint: disable=pointless-string-statement + filter_rel_fields_field: Dict[str, str] = {} + """ + Declare the related field field for filtering:: + + filter_rel_fields_field = { + "": "", "") + } + """ # pylint: disable=pointless-string-statement + + def _get_related_filter(self, datamodel, column_name: str, value: str) -> Filters: + filter_field = self.filter_rel_fields_field.get(column_name) + filters = datamodel.get_filters([filter_field]) + if value: + filters.rest_add_filters( + [{"opr": "sw", "col": filter_field, "value": value}] + ) + return filters + + @expose("/related/", methods=["GET"]) + @protect() + @safe + @rison(get_related_schema) + def related(self, column_name: str, **kwargs): + """Get related fields data + --- + get: + parameters: + - in: path + schema: + type: string + name: column_name + - in: query + name: q + content: + application/json: + schema: + type: object + properties: + page_size: + type: integer + page: + type: integer + filter: + type: string + responses: + 200: + description: Related column data + content: + application/json: + schema: + type: object + properties: + count: + type: integer + result: + type: object + properties: + value: + type: integer + text: + type: string + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + args = kwargs.get("rison", {}) + # handle pagination + page, page_size = self._handle_page_args(args) + try: + datamodel = self.datamodel.get_related_interface(column_name) + except KeyError: + return self.response_404() + page, page_size = self._sanitize_page_args(page, page_size) + # handle ordering + order_field = self.order_rel_fields.get(column_name) + if order_field: + order_column, order_direction = order_field + else: + order_column, order_direction = "", "" + # handle filters + filters = self._get_related_filter(datamodel, column_name, args.get("filter")) + # Make the query + count, values = datamodel.query( + filters, order_column, order_direction, page=page, page_size=page_size + ) + # produce response + result = [ + {"value": datamodel.get_pk_value(value), "text": str(value)} + for value in values + ] + return self.response(200, count=count, result=result) + + class CsvResponse(Response): # pylint: disable=too-many-ancestors """ Override Response to take into account csv encoding from config.py diff --git a/superset/views/dashboard/api.py b/superset/views/dashboard/api.py index 284fe4b39..506847873 100644 --- a/superset/views/dashboard/api.py +++ b/superset/views/dashboard/api.py @@ -18,7 +18,6 @@ import json import re from flask import current_app, g, request -from flask_appbuilder import ModelRestApi from flask_appbuilder.api import expose, protect, safe from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import fields, post_load, pre_load, Schema, ValidationError @@ -29,7 +28,7 @@ import superset.models.core as models from superset import appbuilder from superset.exceptions import SupersetException from superset.utils import core as utils -from superset.views.base import BaseSupersetSchema +from superset.views.base import BaseSupersetModelRestApi, BaseSupersetSchema from .mixin import DashboardMixin @@ -157,7 +156,7 @@ class DashboardPutSchema(BaseDashboardSchema): return self.instance -class DashboardRestApi(DashboardMixin, ModelRestApi): +class DashboardRestApi(DashboardMixin, BaseSupersetModelRestApi): datamodel = SQLAInterface(models.Dashboard) resource_name = "dashboard" @@ -171,6 +170,7 @@ class DashboardRestApi(DashboardMixin, ModelRestApi): "put": "edit", "delete": "delete", "info": "list", + "related": "list", } exclude_route_methods = ("info",) show_columns = [ @@ -185,10 +185,28 @@ class DashboardRestApi(DashboardMixin, ModelRestApi): "table_names", "charts", ] + order_columns = ["dashboard_title", "changed_on", "published", "changed_by_fk"] + list_columns = [ + "id", + "dashboard_title", + "url", + "published", + "owners_json", + "changed_by.username", + "changed_by_name", + "changed_by_url", + "changed_on", + ] add_model_schema = DashboardPostSchema() edit_model_schema = DashboardPutSchema() + order_rel_fields = { + "slices": ("slice_name", "asc"), + "owners": ("first_name", "asc"), + } + filter_rel_fields_field = {"owners": "first_name", "slices": "slice_name"} + @expose("/", methods=["POST"]) @protect() @safe diff --git a/tests/dashboard_api_tests.py b/tests/dashboard_api_tests.py index 1e1a3ee0c..9a60d0a42 100644 --- a/tests/dashboard_api_tests.py +++ b/tests/dashboard_api_tests.py @@ -18,6 +18,7 @@ import json from typing import List +import prison from flask_appbuilder.security.sqla import models as ab_models from superset import db, security_manager @@ -332,10 +333,7 @@ class DashboardApiTests(SupersetTestCase): def test_update_dashboard_not_owned(self): """ - Dashboard API: Test update slug formatting - """ - """ - Dashboard API: Test delete try not owned + Dashboard API: Test update dashboard not owner """ user_alpha1 = self.create_user( "alpha1", "password", "Alpha", email="alpha1@superset.org" @@ -353,3 +351,61 @@ class DashboardApiTests(SupersetTestCase): db.session.delete(user_alpha1) db.session.delete(user_alpha2) db.session.commit() + + def test_get_related_owners(self): + """ + Dashboard API: Test dashboard get related owners + """ + self.login(username="admin") + uri = f"api/v1/dashboard/related/owners" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + response = json.loads(rv.data.decode("utf-8")) + expected_response = { + "count": 6, + "result": [ + {"text": "admin user", "value": 1}, + {"text": "alpha user", "value": 5}, + {"text": "explore_beta user", "value": 6}, + {"text": "gamma user", "value": 2}, + {"text": "gamma2 user", "value": 3}, + {"text": "gamma_sqllab user", "value": 4}, + ], + } + self.assertEqual(response["count"], expected_response["count"]) + # This is needed to be implemented like this because ordering varies between + # postgres and mysql + for result in expected_response["result"]: + self.assertIn(result, response["result"]) + + def test_get_filter_related_owners(self): + """ + Dashboard API: Test dashboard get filter related owners + """ + self.login(username="admin") + argument = {"filter": "a"} + uri = "api/v1/dashboard/related/owners?{}={}".format( + "q", prison.dumps(argument) + ) + + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + response = json.loads(rv.data.decode("utf-8")) + expected_response = { + "count": 2, + "result": [ + {"text": "admin user", "value": 1}, + {"text": "alpha user", "value": 5}, + ], + } + self.assertEqual(response, expected_response) + + def test_get_related_fail(self): + """ + Dashboard API: Test dashboard get related fail + """ + self.login(username="admin") + uri = "api/v1/dashboard/related/owner" + + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404)