feat: custom favorite filter for dashboards, charts and saved queries (#11083)

* feat: custom favorite filter for dashboards

* lint and sort

* add favored for charts

* fix tests and lint

* more tests and saved query filter

* fix tests

* fix tests

* lint

* lint and fix conflict

* remove unnecessary prop

* separate tests
This commit is contained in:
Daniel Vaz Gaspar 2020-10-01 23:08:40 +01:00 committed by GitHub
parent 07716ffd76
commit 4c85d33109
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 366 additions and 37 deletions

View File

@ -41,7 +41,7 @@ from superset.charts.commands.exceptions import (
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"]

View File

@ -24,6 +24,7 @@ from superset import security_manager
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 @@ class ChartAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
)
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():

View File

@ -40,7 +40,11 @@ from superset.dashboards.commands.exceptions import (
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()

View File

@ -25,6 +25,7 @@ from superset.models.core import FavStar
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 @@ class DashboardTitleOrSlugFilter(BaseFilter): # pylint: disable=too-few-public-
)
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:

View File

@ -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(

View File

@ -34,6 +34,7 @@ from superset.queries.saved_queries.commands.exceptions import (
)
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,

View File

@ -24,6 +24,7 @@ from sqlalchemy.orm.query import Query
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 @@ class SavedQueryAllTextFilter(BaseFilter): # pylint: disable=too-few-public-met
)
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:
"""

View File

@ -20,15 +20,22 @@ from typing import Any, Callable, cast, Dict, List, Optional, Set, Tuple, Type,
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 @@ class RelatedFieldFilter:
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

View File

@ -24,12 +24,14 @@ from unittest import mock
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.base_tests import SupersetTestCase
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 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin):
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 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin):
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

View File

@ -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_api_tests import ApiOwnersTestCaseMixin
from tests.base_tests import SupersetTestCase
DASHBOARDS_FIXTURE_COUNT = 10
class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
resource_name = "dashboard"
@ -78,6 +82,32 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
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 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
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 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
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 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin):
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):
"""

View File

@ -21,18 +21,19 @@ from typing import Optional
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 @@ class TestSavedQueryApi(SupersetTestCase):
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 @@ class TestSavedQueryApi(SupersetTestCase):
)
)
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 @@ class TestSavedQueryApi(SupersetTestCase):
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