chore: Migrate /superset/favstar to API v1 (#23165)

Co-authored-by: hughhhh <hughmil3s@gmail.com>
This commit is contained in:
Diego Medina 2023-03-29 17:42:23 -03:00 committed by GitHub
parent 97b5cdd588
commit f2be53dd53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 548 additions and 45 deletions

View File

@ -184,8 +184,12 @@ describe('Charts list', () => {
});
it('should allow to favorite/unfavorite', () => {
cy.intercept(`/superset/favstar/slice/*/select/`).as('select');
cy.intercept(`/superset/favstar/slice/*/unselect/`).as('unselect');
cy.intercept({ url: `/api/v1/chart/*/favorites/`, method: 'POST' }).as(
'select',
);
cy.intercept({ url: `/api/v1/chart/*/favorites/`, method: 'DELETE' }).as(
'unselect',
);
setGridMode('card');
orderAlphabetical();

View File

@ -139,11 +139,15 @@ export function interceptLog() {
}
export function interceptFav() {
cy.intercept(`/superset/favstar/Dashboard/*/select/`).as('select');
cy.intercept({ url: `/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
'select',
);
}
export function interceptUnfav() {
cy.intercept(`/superset/favstar/Dashboard/*/unselect/`).as('unselect');
cy.intercept({ url: `/api/v1/dashboard/*/favorites/`, method: 'POST' }).as(
'unselect',
);
}
export function interceptDataset() {

View File

@ -75,7 +75,7 @@ test('render content on tooltip', async () => {
expect(screen.getByRole('button')).toBeInTheDocument();
});
test('Call fetchFaveStar only on the first render', async () => {
test('Call fetchFaveStar on first render and on itemId change', async () => {
const props = {
itemId: 3,
fetchFaveStar: jest.fn(),
@ -92,5 +92,5 @@ test('Call fetchFaveStar only on the first render', async () => {
expect(props.fetchFaveStar).toBeCalledWith(props.itemId);
rerender(<FaveStar {...{ ...props, itemId: 2 }} />);
expect(props.fetchFaveStar).toBeCalledTimes(1);
expect(props.fetchFaveStar).toBeCalledTimes(2);
});

View File

@ -17,8 +17,8 @@
* under the License.
*/
import React, { useCallback } from 'react';
import { css, t, styled, useComponentDidMount } from '@superset-ui/core';
import React, { useCallback, useEffect } from 'react';
import { css, t, styled } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
@ -45,11 +45,9 @@ const FaveStar = ({
saveFaveStar,
fetchFaveStar,
}: FaveStarProps) => {
useComponentDidMount(() => {
if (fetchFaveStar) {
fetchFaveStar(itemId);
}
});
useEffect(() => {
fetchFaveStar?.(itemId);
}, [fetchFaveStar, itemId]);
const onClick = useCallback(
(e: React.MouseEvent) => {

View File

@ -18,6 +18,7 @@
*/
/* eslint camelcase: 0 */
import { ActionCreators as UndoActionCreators } from 'redux-undo';
import rison from 'rison';
import {
ensureIsArray,
t,
@ -82,7 +83,6 @@ export function removeSlice(sliceId) {
return { type: REMOVE_SLICE, sliceId };
}
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
export function toggleFaveStar(isStarred) {
return { type: TOGGLE_FAVE_STAR, isStarred };
@ -92,10 +92,10 @@ export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
export function fetchFaveStar(id) {
return function fetchFaveStarThunk(dispatch) {
return SupersetClient.get({
endpoint: `${FAVESTAR_BASE_URL}/${id}/count/`,
endpoint: `/api/v1/dashboard/favorite_status/?q=${rison.encode([id])}`,
})
.then(({ json }) => {
if (json.count > 0) dispatch(toggleFaveStar(true));
dispatch(toggleFaveStar(!!json?.result?.[0]?.value));
})
.catch(() =>
dispatch(
@ -112,10 +112,14 @@ export function fetchFaveStar(id) {
export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
export function saveFaveStar(id, isStarred) {
return function saveFaveStarThunk(dispatch) {
const urlSuffix = isStarred ? 'unselect' : 'select';
return SupersetClient.get({
endpoint: `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`,
})
const endpoint = `/api/v1/dashboard/${id}/favorites/`;
const apiCall = isStarred
? SupersetClient.delete({
endpoint,
})
: SupersetClient.post({ endpoint });
return apiCall
.then(() => {
dispatch(toggleFaveStar(!isStarred));
})

View File

@ -279,7 +279,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
}, [addDangerToast, datasets, datasetsApiError, dispatch]);
if (error) throw error; // caught in error boundary
if (!readyToRender) return <Loading />;
if (!readyToRender || !isDashboardHydrated.current) return <Loading />;
return (
<>

View File

@ -17,6 +17,7 @@
* under the License.
*/
/* eslint camelcase: 0 */
import rison from 'rison';
import { Dataset } from '@superset-ui/chart-controls';
import { t, SupersetClient, QueryFormData } from '@superset-ui/core';
import { Dispatch } from 'redux';
@ -27,8 +28,6 @@ import {
import { Slice } from 'src/types/Chart';
import { SaveActionType } from 'src/explore/types';
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
export const UPDATE_FORM_DATA_BY_DATASOURCE = 'UPDATE_FORM_DATA_BY_DATASOURCE';
export function updateFormDataByDatasource(
prevDatasource: Dataset,
@ -66,11 +65,9 @@ export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
export function fetchFaveStar(sliceId: string) {
return function (dispatch: Dispatch) {
SupersetClient.get({
endpoint: `${FAVESTAR_BASE_URL}/${sliceId}/count/`,
endpoint: `/api/v1/chart/favorite_status/?q=${rison.encode([sliceId])}`,
}).then(({ json }) => {
if (json.count > 0) {
dispatch(toggleFaveStar(true));
}
dispatch(toggleFaveStar(!!json?.result?.[0]?.value));
});
};
}
@ -78,10 +75,14 @@ export function fetchFaveStar(sliceId: string) {
export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
export function saveFaveStar(sliceId: string, isStarred: boolean) {
return function (dispatch: Dispatch) {
const urlSuffix = isStarred ? 'unselect' : 'select';
SupersetClient.get({
endpoint: `${FAVESTAR_BASE_URL}/${sliceId}/${urlSuffix}/`,
})
const endpoint = `/api/v1/chart/${sliceId}/favorites/`;
const apiCall = isStarred
? SupersetClient.delete({
endpoint,
})
: SupersetClient.post({ endpoint });
apiCall
.then(() => dispatch(toggleFaveStar(!isStarred)))
.catch(() => {
dispatch(

View File

@ -91,7 +91,9 @@ jest.mock('lodash/debounce', () => ({
fetchMock.post('glob:*/api/v1/explore/form_data*', { key: KEY });
fetchMock.put('glob:*/api/v1/explore/form_data*', { key: KEY });
fetchMock.get('glob:*/api/v1/explore/form_data*', {});
fetchMock.get('glob:*/favstar/slice*', { count: 0 });
fetchMock.get('glob:*/api/v1/chart/favorite_status*', {
result: [{ value: true }],
});
const defaultPath = '/explore/';
const renderWithRouter = ({

View File

@ -542,11 +542,6 @@ export function useImportResource(
return { state, importResource };
}
enum FavStarClassName {
CHART = 'slice',
DASHBOARD = 'Dashboard',
}
type FavoriteStatusResponse = {
result: Array<{
id: string;
@ -599,15 +594,17 @@ export function useFavoriteStatus(
const saveFaveStar = useCallback(
(id: number, isStarred: boolean) => {
const urlSuffix = isStarred ? 'unselect' : 'select';
SupersetClient.get({
endpoint: `/superset/favstar/${
type === 'chart' ? FavStarClassName.CHART : FavStarClassName.DASHBOARD
}/${id}/${urlSuffix}/`,
}).then(
({ json }) => {
const endpoint = `/api/v1/${type}/${id}/favorites/`;
const apiCall = isStarred
? SupersetClient.delete({
endpoint,
})
: SupersetClient.post({ endpoint });
apiCall.then(
() => {
updateFavoriteStatus({
[id]: (json as { count: number })?.count > 0,
[id]: !isStarred,
});
},
createErrorHandler(errMsg =>

View File

@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# pylint: disable=too-many-lines
import json
import logging
from datetime import datetime
@ -111,6 +112,8 @@ class ChartRestApi(BaseSupersetModelRestApi):
"bulk_delete", # not using RouteMethod since locally defined
"viz_types",
"favorite_status",
"add_favorite",
"remove_favorite",
"thumbnail",
"screenshot",
"cache_screenshot",
@ -848,6 +851,94 @@ class ChartRestApi(BaseSupersetModelRestApi):
]
return self.response(200, result=res)
@expose("/<pk>/favorites/", methods=["POST"])
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".add_favorite",
log_to_statsd=False,
)
def add_favorite(self, pk: int) -> Response:
"""Marks the chart as favorite
---
post:
description: >-
Marks the chart as favorite for the current user
parameters:
- in: path
schema:
type: integer
name: pk
responses:
200:
description: Chart added to favorites
content:
application/json:
schema:
type: object
properties:
result:
type: object
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
return self.response_404()
ChartDAO.add_favorite(chart)
return self.response(200, result="OK")
@expose("/<pk>/favorites/", methods=["DELETE"])
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".remove_favorite",
log_to_statsd=False,
)
def remove_favorite(self, pk: int) -> Response:
"""Remove the chart from the user favorite list
---
delete:
description: >-
Remove the chart from the user favorite list
parameters:
- in: path
schema:
type: integer
name: pk
responses:
200:
description: Chart removed from favorites
content:
application/json:
schema:
type: object
properties:
result:
type: object
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
chart = ChartDAO.find_by_id(pk)
if not chart:
return self.response_404()
ChartDAO.remove_favorite(chart)
return self.response(200, result="OK")
@expose("/import/", methods=["POST"])
@protect()
@statsd_metrics

View File

@ -16,6 +16,7 @@
# under the License.
# pylint: disable=arguments-renamed
import logging
from datetime import datetime
from typing import List, Optional, TYPE_CHECKING
from sqlalchemy.exc import SQLAlchemyError
@ -82,3 +83,32 @@ class ChartDAO(BaseDAO):
)
.all()
]
@staticmethod
def add_favorite(chart: Slice) -> None:
ids = ChartDAO.favorited_ids([chart])
if chart.id not in ids:
db.session.add(
FavStar(
class_name=FavStarClassName.CHART,
obj_id=chart.id,
user_id=get_user_id(),
dttm=datetime.now(),
)
)
db.session.commit()
@staticmethod
def remove_favorite(chart: Slice) -> None:
fav = (
db.session.query(FavStar)
.filter(
FavStar.class_name == FavStarClassName.CHART,
FavStar.obj_id == chart.id,
FavStar.user_id == get_user_id(),
)
.one_or_none()
)
if fav:
db.session.delete(fav)
db.session.commit()

View File

@ -128,6 +128,8 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"test_connection": "read",
"validate_parameters": "read",
"favorite_status": "read",
"add_favorite": "read",
"remove_favorite": "read",
"thumbnail": "read",
"import_": "write",
"refresh": "write",

View File

@ -141,6 +141,8 @@ class DashboardRestApi(BaseSupersetModelRestApi):
RouteMethod.RELATED,
"bulk_delete", # not using RouteMethod since locally defined
"favorite_status",
"add_favorite",
"remove_favorite",
"get_charts",
"get_datasets",
"get_embedded",
@ -1001,6 +1003,94 @@ class DashboardRestApi(BaseSupersetModelRestApi):
]
return self.response(200, result=res)
@expose("/<pk>/favorites/", methods=["POST"])
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".add_favorite",
log_to_statsd=False,
)
def add_favorite(self, pk: int) -> Response:
"""Marks the dashboard as favorite
---
post:
description: >-
Marks the dashboard as favorite for the current user
parameters:
- in: path
schema:
type: integer
name: pk
responses:
200:
description: Dashboard added to favorites
content:
application/json:
schema:
type: object
properties:
result:
type: object
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
dashboard = DashboardDAO.find_by_id(pk)
if not dashboard:
return self.response_404()
DashboardDAO.add_favorite(dashboard)
return self.response(200, result="OK")
@expose("/<pk>/favorites/", methods=["DELETE"])
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".remove_favorite",
log_to_statsd=False,
)
def remove_favorite(self, pk: int) -> Response:
"""Remove the dashboard from the user favorite list
---
delete:
description: >-
Remove the dashboard from the user favorite list
parameters:
- in: path
schema:
type: integer
name: pk
responses:
200:
description: Dashboard removed from favorites
content:
application/json:
schema:
type: object
properties:
result:
type: object
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
dashboard = DashboardDAO.find_by_id(pk)
if not dashboard:
return self.response_404()
DashboardDAO.remove_favorite(dashboard)
return self.response(200, result="OK")
@expose("/import/", methods=["POST"])
@protect()
@statsd_metrics

View File

@ -307,3 +307,32 @@ class DashboardDAO(BaseDAO):
)
.all()
]
@staticmethod
def add_favorite(dashboard: Dashboard) -> None:
ids = DashboardDAO.favorited_ids([dashboard])
if dashboard.id not in ids:
db.session.add(
FavStar(
class_name=FavStarClassName.DASHBOARD,
obj_id=dashboard.id,
user_id=get_user_id(),
dttm=datetime.now(),
)
)
db.session.commit()
@staticmethod
def remove_favorite(dashboard: Dashboard) -> None:
fav = (
db.session.query(FavStar)
.filter(
FavStar.class_name == FavStarClassName.DASHBOARD,
FavStar.obj_id == dashboard.id,
FavStar.user_id == get_user_id(),
)
.one_or_none()
)
if fav:
db.session.delete(fav)
db.session.commit()

View File

@ -1787,6 +1787,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
@has_access_api
@event_logger.log_this
@expose("/favstar/<class_name>/<int:obj_id>/<action>/")
@deprecated()
def favstar( # pylint: disable=no-self-use
self, class_name: str, obj_id: int, action: str
) -> FlaskResponse:

View File

@ -1252,6 +1252,75 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixin):
if res["id"] in users_favorite_ids:
assert res["value"]
def test_add_favorite(self):
"""
Dataset API: Test add chart to favorites
"""
chart = Slice(
id=100,
datasource_id=1,
datasource_type="table",
datasource_name="tmp_perm_table",
slice_name="slice_name",
)
db.session.add(chart)
db.session.commit()
self.login(username="admin")
uri = f"api/v1/chart/favorite_status/?q={prison.dumps([chart.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is False
uri = f"api/v1/chart/{chart.id}/favorites/"
self.client.post(uri)
uri = f"api/v1/chart/favorite_status/?q={prison.dumps([chart.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is True
db.session.delete(chart)
db.session.commit()
def test_remove_favorite(self):
"""
Dataset API: Test remove chart from favorites
"""
chart = Slice(
id=100,
datasource_id=1,
datasource_type="table",
datasource_name="tmp_perm_table",
slice_name="slice_name",
)
db.session.add(chart)
db.session.commit()
self.login(username="admin")
uri = f"api/v1/chart/{chart.id}/favorites/"
self.client.post(uri)
uri = f"api/v1/chart/favorite_status/?q={prison.dumps([chart.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is True
uri = f"api/v1/chart/{chart.id}/favorites/"
self.client.delete(uri)
uri = f"api/v1/chart/favorite_status/?q={prison.dumps([chart.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is False
db.session.delete(chart)
db.session.commit()
def test_get_time_range(self):
"""
Chart API: Test get actually time range from human readable string

View File

@ -720,6 +720,75 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixi
if res["id"] in users_favorite_ids:
assert res["value"]
def test_add_favorite(self):
"""
Dataset API: Test add dashboard to favorites
"""
dashboard = Dashboard(
id=100,
dashboard_title="test_dashboard",
slug="test_slug",
slices=[],
published=True,
)
db.session.add(dashboard)
db.session.commit()
self.login(username="admin")
uri = f"api/v1/dashboard/favorite_status/?q={prison.dumps([dashboard.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is False
uri = f"api/v1/dashboard/{dashboard.id}/favorites/"
self.client.post(uri)
uri = f"api/v1/dashboard/favorite_status/?q={prison.dumps([dashboard.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is True
db.session.delete(dashboard)
db.session.commit()
def test_remove_favorite(self):
"""
Dataset API: Test remove dashboard from favorites
"""
dashboard = Dashboard(
id=100,
dashboard_title="test_dashboard",
slug="test_slug",
slices=[],
published=True,
)
db.session.add(dashboard)
db.session.commit()
self.login(username="admin")
uri = f"api/v1/dashboard/{dashboard.id}/favorites/"
self.client.post(uri)
uri = f"api/v1/dashboard/favorite_status/?q={prison.dumps([dashboard.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is True
uri = f"api/v1/dashboard/{dashboard.id}/favorites/"
self.client.delete(uri)
uri = f"api/v1/dashboard/favorite_status/?q={prison.dumps([dashboard.id])}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
for res in data["result"]:
assert res["value"] is False
db.session.delete(dashboard)
db.session.commit()
@pytest.mark.usefixtures("create_dashboards")
def test_get_dashboards_not_favorite_filter(self):
"""

View File

@ -65,3 +65,36 @@ def test_datasource_find_by_id_skip_base_filter_not_found(
125326326, session=session_with_data, skip_base_filter=True
)
assert result is None
def test_add_favorite(session_with_data: Session) -> None:
from superset.charts.dao import ChartDAO
chart = ChartDAO.find_by_id(1, session=session_with_data, skip_base_filter=True)
if not chart:
return
assert len(ChartDAO.favorited_ids([chart])) == 0
ChartDAO.add_favorite(chart)
assert len(ChartDAO.favorited_ids([chart])) == 1
ChartDAO.add_favorite(chart)
assert len(ChartDAO.favorited_ids([chart])) == 1
def test_remove_favorite(session_with_data: Session) -> None:
from superset.charts.dao import ChartDAO
chart = ChartDAO.find_by_id(1, session=session_with_data, skip_base_filter=True)
if not chart:
return
assert len(ChartDAO.favorited_ids([chart])) == 0
ChartDAO.add_favorite(chart)
assert len(ChartDAO.favorited_ids([chart])) == 1
ChartDAO.remove_favorite(chart)
assert len(ChartDAO.favorited_ids([chart])) == 0
ChartDAO.remove_favorite(chart)
assert len(ChartDAO.favorited_ids([chart])) == 0

View File

@ -0,0 +1,79 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Iterator
import pytest
from sqlalchemy.orm.session import Session
@pytest.fixture
def session_with_data(session: Session) -> Iterator[Session]:
from superset.models.dashboard import Dashboard
engine = session.get_bind()
Dashboard.metadata.create_all(engine) # pylint: disable=no-member
dashboard_obj = Dashboard(
id=100,
dashboard_title="test_dashboard",
slug="test_slug",
slices=[],
published=True,
)
session.add(dashboard_obj)
session.commit()
yield session
session.rollback()
def test_add_favorite(session_with_data: Session) -> None:
from superset.dashboards.dao import DashboardDAO
dashboard = DashboardDAO.find_by_id(
100, session=session_with_data, skip_base_filter=True
)
if not dashboard:
return
assert len(DashboardDAO.favorited_ids([dashboard])) == 0
DashboardDAO.add_favorite(dashboard)
assert len(DashboardDAO.favorited_ids([dashboard])) == 1
DashboardDAO.add_favorite(dashboard)
assert len(DashboardDAO.favorited_ids([dashboard])) == 1
def test_remove_favorite(session_with_data: Session) -> None:
from superset.dashboards.dao import DashboardDAO
dashboard = DashboardDAO.find_by_id(
100, session=session_with_data, skip_base_filter=True
)
if not dashboard:
return
assert len(DashboardDAO.favorited_ids([dashboard])) == 0
DashboardDAO.add_favorite(dashboard)
assert len(DashboardDAO.favorited_ids([dashboard])) == 1
DashboardDAO.remove_favorite(dashboard)
assert len(DashboardDAO.favorited_ids([dashboard])) == 0
DashboardDAO.remove_favorite(dashboard)
assert len(DashboardDAO.favorited_ids([dashboard])) == 0