diff --git a/superset/charts/api.py b/superset/charts/api.py index 4999654c67a2e..cdf633ee6fd58 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -41,7 +41,7 @@ ChartUpdateFailedError, ) from superset.charts.commands.update import UpdateChartCommand -from superset.charts.filters import ChartAllTextFilter, ChartFilter +from superset.charts.filters import ChartAllTextFilter, ChartFavoriteFilter, ChartFilter from superset.charts.schemas import ( CHART_SCHEMAS, ChartDataQueryContextSchema, @@ -143,13 +143,17 @@ class ChartRestApi(BaseSupersetModelRestApi): "datasource_name", "datasource_type", "description", + "id", "owners", "slice_name", "viz_type", ] base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] - search_filters = {"slice_name": [ChartAllTextFilter]} + search_filters = { + "id": [ChartFavoriteFilter], + "slice_name": [ChartAllTextFilter], + } # Will just affect _info endpoint edit_columns = ["slice_name"] diff --git a/superset/charts/filters.py b/superset/charts/filters.py index c352b01d6d57c..5af8c9e0320bd 100644 --- a/superset/charts/filters.py +++ b/superset/charts/filters.py @@ -24,6 +24,7 @@ from superset.connectors.sqla.models import SqlaTable from superset.models.slice import Slice from superset.views.base import BaseFilter +from superset.views.base_api import BaseFavoriteFilter class ChartAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods @@ -44,6 +45,16 @@ def apply(self, query: Query, value: Any) -> Query: ) +class ChartFavoriteFilter(BaseFavoriteFilter): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all charts that a user has favored + """ + + arg_name = "chart_is_fav" + class_name = "slice" + model = Slice + + class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods def apply(self, query: Query, value: Any) -> Query: if security_manager.can_access_all_datasources(): diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index c9c1fc3e24b45..43fabd09f872f 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -40,7 +40,11 @@ DashboardUpdateFailedError, ) from superset.dashboards.commands.update import UpdateDashboardCommand -from superset.dashboards.filters import DashboardFilter, DashboardTitleOrSlugFilter +from superset.dashboards.filters import ( + DashboardFavoriteFilter, + DashboardFilter, + DashboardTitleOrSlugFilter, +) from superset.dashboards.schemas import ( DashboardPostSchema, DashboardPutSchema, @@ -142,8 +146,18 @@ class DashboardRestApi(BaseSupersetModelRestApi): ] edit_columns = add_columns - search_columns = ("dashboard_title", "slug", "owners", "published", "created_by") - search_filters = {"dashboard_title": [DashboardTitleOrSlugFilter]} + search_columns = ( + "created_by", + "dashboard_title", + "id", + "owners", + "published", + "slug", + ) + search_filters = { + "dashboard_title": [DashboardTitleOrSlugFilter], + "id": [DashboardFavoriteFilter], + } base_order = ("changed_on", "desc") add_model_schema = DashboardPostSchema() diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py index f13f36e1afc07..020aa0294ffea 100644 --- a/superset/dashboards/filters.py +++ b/superset/dashboards/filters.py @@ -25,6 +25,7 @@ from superset.models.dashboard import Dashboard from superset.models.slice import Slice from superset.views.base import BaseFilter, get_user_roles +from superset.views.base_api import BaseFavoriteFilter class DashboardTitleOrSlugFilter(BaseFilter): # pylint: disable=too-few-public-methods @@ -43,6 +44,18 @@ def apply(self, query: Query, value: Any) -> Query: ) +class DashboardFavoriteFilter( + BaseFavoriteFilter +): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all dashboards that a user has favored + """ + + arg_name = "dashboard_is_fav" + class_name = "Dashboard" + model = Dashboard + + class DashboardFilter(BaseFilter): # pylint: disable=too-few-public-methods """ List dashboards with the following criteria: diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index c101b772c0258..f7d4848879261 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -182,6 +182,9 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin): backref=backref("saved_queries", cascade="all, delete-orphan"), ) + def __repr__(self) -> str: + return str(self.label) + @property def pop_tab_link(self) -> Markup: return Markup( diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index a84ee722ff747..09cb82ac22c40 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -34,6 +34,7 @@ ) from superset.queries.saved_queries.filters import ( SavedQueryAllTextFilter, + SavedQueryFavoriteFilter, SavedQueryFilter, ) from superset.queries.saved_queries.schemas import ( @@ -101,7 +102,11 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): "changed_on_delta_humanized", ] - search_filters = {"label": [SavedQueryAllTextFilter]} + search_columns = ["id", "label"] + search_filters = { + "id": [SavedQueryFavoriteFilter], + "label": [SavedQueryAllTextFilter], + } apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, diff --git a/superset/queries/saved_queries/filters.py b/superset/queries/saved_queries/filters.py index 09636cc3a8a47..c53ff5619d1d1 100644 --- a/superset/queries/saved_queries/filters.py +++ b/superset/queries/saved_queries/filters.py @@ -24,6 +24,7 @@ from superset.models.sql_lab import SavedQuery from superset.views.base import BaseFilter +from superset.views.base_api import BaseFavoriteFilter class SavedQueryAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods @@ -44,6 +45,19 @@ def apply(self, query: Query, value: Any) -> Query: ) +class SavedQueryFavoriteFilter( + BaseFavoriteFilter +): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all saved queries that a user has + favored + """ + + arg_name = "saved_query_is_fav" + class_name = "query" + model = SavedQuery + + class SavedQueryFilter(BaseFilter): # pylint: disable=too-few-public-methods def apply(self, query: BaseQuery, value: Any) -> BaseQuery: """ diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 458fa83e3876a..5a59b8014a612 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -20,15 +20,22 @@ from apispec import APISpec from apispec.exceptions import DuplicateComponentNameError -from flask import Blueprint, Response +from flask import Blueprint, g, Response from flask_appbuilder import AppBuilder, ModelRestApi from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.models.filters import BaseFilter, Filters from flask_appbuilder.models.sqla.filters import FilterStartsWith from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_babel import lazy_gettext as _ from marshmallow import fields, Schema -from sqlalchemy import distinct, func - +from sqlalchemy import and_, distinct, func +from sqlalchemy.orm.query import Query + +from superset.extensions import db, security_manager +from superset.models.core import FavStar +from superset.models.dashboard import Dashboard +from superset.models.slice import Slice +from superset.sql_lab import Query as SqllabQuery from superset.stats_logger import BaseStatsLogger from superset.typing import FlaskResponse from superset.utils.core import time_function @@ -84,6 +91,31 @@ def __init__(self, field_name: str, filter_class: Type[BaseFilter]): self.filter_class = filter_class +class BaseFavoriteFilter(BaseFilter): # pylint: disable=too-few-public-methods + """ + Base Custom filter for the GET list that filters all dashboards, slices + that a user has favored or not + """ + + name = _("Is favorite") + arg_name = "" + class_name = "" + """ The FavStar class_name to user """ + model: Type[Union[Dashboard, Slice, SqllabQuery]] = Dashboard + """ The SQLAlchemy model """ + + def apply(self, query: Query, value: Any) -> Query: + # If anonymous user filter nothing + if security_manager.current_user is None: + return query + users_favorite_query = db.session.query(FavStar.obj_id).filter( + and_(FavStar.user_id == g.user.id, FavStar.class_name == self.class_name) + ) + if value: + return query.filter(and_(self.model.id.in_(users_favorite_query))) + return query.filter(and_(~self.model.id.in_(users_favorite_query))) + + class BaseSupersetModelRestApi(ModelRestApi): """ Extends FAB's ModelResApi to implement specific superset generic functionality diff --git a/tests/charts/api_tests.py b/tests/charts/api_tests.py index 1d353be5d3881..9e6dca7805043 100644 --- a/tests/charts/api_tests.py +++ b/tests/charts/api_tests.py @@ -24,12 +24,14 @@ import humanize import prison import pytest +from sqlalchemy import and_ from sqlalchemy.sql import func from superset.utils.core import get_example_database from tests.test_app import app from superset.connectors.connector_registry import ConnectorRegistry from superset.extensions import db, security_manager +from superset.models.core import FavStar from superset.models.dashboard import Dashboard from superset.models.slice import Slice from superset.utils import core as utils @@ -38,6 +40,7 @@ from tests.fixtures.query_context import get_query_context CHART_DATA_URI = "api/v1/chart/data" +CHARTS_FIXTURE_COUNT = 10 class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin): @@ -78,6 +81,30 @@ def insert_chart( db.session.commit() return slice + @pytest.fixture() + def create_charts(self): + with self.create_app().app_context(): + charts = [] + admin = self.get_user("admin") + for cx in range(CHARTS_FIXTURE_COUNT - 1): + charts.append(self.insert_chart(f"name{cx}", [admin.id], 1)) + fav_charts = [] + for cx in range(round(CHARTS_FIXTURE_COUNT / 2)): + fav_star = FavStar( + user_id=admin.id, class_name="slice", obj_id=charts[cx].id + ) + db.session.add(fav_star) + db.session.commit() + fav_charts.append(fav_star) + yield charts + + # rollback changes + for chart in charts: + db.session.delete(chart) + for fav_chart in fav_charts: + db.session.delete(fav_chart) + db.session.commit() + def test_delete_chart(self): """ Chart API: Test delete @@ -659,6 +686,53 @@ def test_get_charts_custom_filter(self): db.session.delete(chart5) db.session.commit() + @pytest.mark.usefixtures("create_charts") + def test_get_charts_favorite_filter(self): + """ + Chart API: Test get charts favorite filter + """ + admin = self.get_user("admin") + users_favorite_query = db.session.query(FavStar.obj_id).filter( + and_(FavStar.user_id == admin.id, FavStar.class_name == "slice") + ) + expected_models = ( + db.session.query(Slice) + .filter(and_(Slice.id.in_(users_favorite_query))) + .order_by(Slice.slice_name.asc()) + .all() + ) + + arguments = { + "filters": [{"col": "id", "opr": "chart_is_fav", "value": True}], + "order_column": "slice_name", + "order_direction": "asc", + "keys": ["none"], + "columns": ["slice_name"], + } + self.login(username="admin") + uri = f"api/v1/chart/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + assert len(expected_models) == data["count"] + + for i, expected_model in enumerate(expected_models): + assert expected_model.slice_name == data["result"][i]["slice_name"] + + # Test not favorite charts + expected_models = ( + db.session.query(Slice) + .filter(and_(~Slice.id.in_(users_favorite_query))) + .order_by(Slice.slice_name.asc()) + .all() + ) + arguments["filters"][0]["value"] = False + uri = f"api/v1/chart/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + assert len(expected_models) == data["count"] + def test_get_charts_page(self): """ Chart API: Test get charts filter diff --git a/tests/dashboards/api_tests.py b/tests/dashboards/api_tests.py index 17cffd411bff8..09c14a0cd9094 100644 --- a/tests/dashboards/api_tests.py +++ b/tests/dashboards/api_tests.py @@ -18,15 +18,16 @@ """Unit tests for Superset""" import json from typing import List, Optional -from datetime import datetime +import pytest import prison -import humanize from sqlalchemy.sql import func import tests.test_app +from sqlalchemy import and_ from superset import db, security_manager from superset.models.dashboard import Dashboard +from superset.models.core import FavStar from superset.models.slice import Slice from superset.views.base import generate_download_headers @@ -34,6 +35,9 @@ from tests.base_tests import SupersetTestCase +DASHBOARDS_FIXTURE_COUNT = 10 + + class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin): resource_name = "dashboard" @@ -78,6 +82,32 @@ def insert_dashboard( db.session.commit() return dashboard + @pytest.fixture() + def create_dashboards(self): + 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]) + ) + fav_dashboards = [] + for cx in range(round(DASHBOARDS_FIXTURE_COUNT / 2)): + 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) + yield dashboards + + # rollback changes + for dashboard in dashboards: + db.session.delete(dashboard) + for fav_dashboard in fav_dashboards: + db.session.delete(fav_dashboard) + db.session.commit() + def test_get_dashboard(self): """ Dashboard API: Test get dashboard @@ -223,19 +253,15 @@ def test_get_dashboards_filter(self): db.session.delete(dashboard) db.session.commit() - def test_get_dashboards_custom_filter(self): + @pytest.mark.usefixtures("create_dashboards") + def test_get_dashboards_title_or_slug_filter(self): """ - Dashboard API: Test get dashboards custom filter + Dashboard API: Test get dashboards title or slug filter """ - admin = self.get_user("admin") - dashboard1 = self.insert_dashboard("foo_a", "ZY_bar", [admin.id]) - dashboard2 = self.insert_dashboard("zy_foo", "slug1", [admin.id]) - dashboard3 = self.insert_dashboard("foo_b", "slug1zy_", [admin.id]) - dashboard4 = self.insert_dashboard("bar", "foo", [admin.id]) - + # Test title filter with ilike arguments = { "filters": [ - {"col": "dashboard_title", "opr": "title_or_slug", "value": "zy_"} + {"col": "dashboard_title", "opr": "title_or_slug", "value": "title1"} ], "order_column": "dashboard_title", "order_direction": "asc", @@ -247,18 +273,25 @@ def test_get_dashboards_custom_filter(self): rv = self.client.get(uri) self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) - self.assertEqual(data["count"], 3) + self.assertEqual(data["count"], 1) expected_response = [ - {"slug": "ZY_bar", "dashboard_title": "foo_a"}, - {"slug": "slug1zy_", "dashboard_title": "foo_b"}, - {"slug": "slug1", "dashboard_title": "zy_foo"}, + {"slug": "slug1", "dashboard_title": "title1"}, ] - for index, item in enumerate(data["result"]): - self.assertEqual(item["slug"], expected_response[index]["slug"]) - self.assertEqual( - item["dashboard_title"], expected_response[index]["dashboard_title"] - ) + assert data["result"] == expected_response + + # Test slug filter with ilike + arguments["filters"][0]["value"] = "slug2" + uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + self.assertEqual(data["count"], 1) + + expected_response = [ + {"slug": "slug2", "dashboard_title": "title2"}, + ] + assert data["result"] == expected_response self.logout() self.login(username="gamma") @@ -268,12 +301,73 @@ def test_get_dashboards_custom_filter(self): data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 0) - # rollback changes - db.session.delete(dashboard1) - db.session.delete(dashboard2) - db.session.delete(dashboard3) - db.session.delete(dashboard4) - db.session.commit() + @pytest.mark.usefixtures("create_dashboards") + def test_get_dashboards_favorite_filter(self): + """ + Dashboard API: Test get dashboards favorite filter + """ + admin = self.get_user("admin") + users_favorite_query = db.session.query(FavStar.obj_id).filter( + and_(FavStar.user_id == admin.id, FavStar.class_name == "Dashboard") + ) + expected_models = ( + db.session.query(Dashboard) + .filter(and_(Dashboard.id.in_(users_favorite_query))) + .order_by(Dashboard.dashboard_title.asc()) + .all() + ) + + arguments = { + "filters": [{"col": "id", "opr": "dashboard_is_fav", "value": True}], + "order_column": "dashboard_title", + "order_direction": "asc", + "keys": ["none"], + "columns": ["dashboard_title"], + } + self.login(username="admin") + uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + assert rv.status_code == 200 + data = json.loads(rv.data.decode("utf-8")) + assert len(expected_models) == data["count"] + + for i, expected_model in enumerate(expected_models): + assert ( + expected_model.dashboard_title == data["result"][i]["dashboard_title"] + ) + + @pytest.mark.usefixtures("create_dashboards") + def test_get_dashboards_not_favorite_filter(self): + """ + Dashboard API: Test get dashboards not favorite filter + """ + admin = self.get_user("admin") + users_favorite_query = db.session.query(FavStar.obj_id).filter( + and_(FavStar.user_id == admin.id, FavStar.class_name == "Dashboard") + ) + expected_models = ( + db.session.query(Dashboard) + .filter(and_(~Dashboard.id.in_(users_favorite_query))) + .order_by(Dashboard.dashboard_title.asc()) + .all() + ) + arguments = { + "filters": [{"col": "id", "opr": "dashboard_is_fav", "value": False}], + "order_column": "dashboard_title", + "order_direction": "asc", + "keys": ["none"], + "columns": ["dashboard_title"], + } + uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}" + self.login(username="admin") + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + assert len(expected_models) == data["count"] + for i, expected_model in enumerate(expected_models): + assert ( + expected_model.dashboard_title == data["result"][i]["dashboard_title"] + ) def test_get_dashboards_no_data_access(self): """ diff --git a/tests/queries/saved_queries/api_tests.py b/tests/queries/saved_queries/api_tests.py index b68ff0861686b..ecc13456af081 100644 --- a/tests/queries/saved_queries/api_tests.py +++ b/tests/queries/saved_queries/api_tests.py @@ -21,18 +21,19 @@ import pytest import prison -from sqlalchemy.sql import func, asc +from sqlalchemy.sql import func, and_ import tests.test_app -from superset import db, security_manager +from superset import db from superset.models.core import Database +from superset.models.core import FavStar from superset.models.sql_lab import SavedQuery from superset.utils.core import get_example_database from tests.base_tests import SupersetTestCase -SAVED_QUERIES_FIXTURE_COUNT = 5 +SAVED_QUERIES_FIXTURE_COUNT = 10 class TestSavedQueryApi(SupersetTestCase): @@ -78,6 +79,7 @@ def insert_default_saved_query( def create_saved_queries(self): with self.create_app().app_context(): saved_queries = [] + admin = self.get_user("admin") for cx in range(SAVED_QUERIES_FIXTURE_COUNT - 1): saved_queries.append( self.insert_default_saved_query( @@ -92,11 +94,22 @@ def create_saved_queries(self): ) ) + fav_saved_queries = [] + for cx in range(round(SAVED_QUERIES_FIXTURE_COUNT / 2)): + fav_star = FavStar( + user_id=admin.id, class_name="query", obj_id=saved_queries[cx].id + ) + db.session.add(fav_star) + db.session.commit() + fav_saved_queries.append(fav_star) + yield saved_queries # rollback changes for saved_query in saved_queries: db.session.delete(saved_query) + for fav_saved_query in fav_saved_queries: + db.session.delete(fav_saved_query) db.session.commit() @pytest.mark.usefixtures("create_saved_queries") @@ -290,6 +303,58 @@ def test_get_list_custom_filter_description_saved_query(self): data = json.loads(rv.data.decode("utf-8")) assert data["count"] == len(all_queries) + @pytest.mark.usefixtures("create_saved_queries") + def test_get_saved_query_favorite_filter(self): + """ + SavedQuery API: Test get saved queries favorite filter + """ + admin = self.get_user("admin") + users_favorite_query = db.session.query(FavStar.obj_id).filter( + and_(FavStar.user_id == admin.id, FavStar.class_name == "query") + ) + expected_models = ( + db.session.query(SavedQuery) + .filter(and_(SavedQuery.id.in_(users_favorite_query))) + .order_by(SavedQuery.label.asc()) + .all() + ) + + arguments = { + "filters": [{"col": "id", "opr": "saved_query_is_fav", "value": True}], + "order_column": "label", + "order_direction": "asc", + "keys": ["none"], + "columns": ["label"], + } + self.login(username="admin") + uri = f"api/v1/saved_query/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + assert len(expected_models) == data["count"] + + for i, expected_model in enumerate(expected_models): + assert expected_model.label == data["result"][i]["label"] + + # Test not favorite saves queries + expected_models = ( + db.session.query(SavedQuery) + .filter( + and_( + ~SavedQuery.id.in_(users_favorite_query), + SavedQuery.created_by == admin, + ) + ) + .order_by(SavedQuery.label.asc()) + .all() + ) + arguments["filters"][0]["value"] = False + uri = f"api/v1/saved_query/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + assert len(expected_models) == data["count"] + def test_info_saved_query(self): """ SavedQuery API: Test info