You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by hu...@apache.org on 2023/03/29 20:42:46 UTC

[superset] branch master updated: chore: Migrate /superset/favstar to API v1 (#23165)

This is an automated email from the ASF dual-hosted git repository.

hugh pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new f2be53dd53 chore: Migrate /superset/favstar to API v1 (#23165)
f2be53dd53 is described below

commit f2be53dd5315984b847ffa6e595268f242c1d882
Author: Diego Medina <di...@gmail.com>
AuthorDate: Wed Mar 29 17:42:23 2023 -0300

    chore: Migrate /superset/favstar to API v1 (#23165)
    
    Co-authored-by: hughhhh <hu...@gmail.com>
---
 .../cypress/integration/chart_list/list.test.ts    |  8 +-
 .../cypress/integration/dashboard/utils.ts         |  8 +-
 .../src/components/FaveStar/FaveStar.test.tsx      |  4 +-
 .../src/components/FaveStar/index.tsx              | 12 ++-
 .../src/dashboard/actions/dashboardState.js        | 18 +++--
 .../src/dashboard/containers/DashboardPage.tsx     |  2 +-
 .../src/explore/actions/exploreActions.ts          | 21 ++---
 .../ExploreViewContainer.test.tsx                  |  4 +-
 superset-frontend/src/views/CRUD/hooks.ts          | 23 +++---
 superset/charts/api.py                             | 91 ++++++++++++++++++++++
 superset/charts/dao.py                             | 30 +++++++
 superset/constants.py                              |  2 +
 superset/dashboards/api.py                         | 90 +++++++++++++++++++++
 superset/dashboards/dao.py                         | 29 +++++++
 superset/views/core.py                             |  1 +
 tests/integration_tests/charts/api_tests.py        | 69 ++++++++++++++++
 tests/integration_tests/dashboards/api_tests.py    | 69 ++++++++++++++++
 tests/unit_tests/charts/dao/dao_tests.py           | 33 ++++++++
 tests/unit_tests/dashboards/dao_tests.py           | 79 +++++++++++++++++++
 19 files changed, 548 insertions(+), 45 deletions(-)

diff --git a/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts b/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts
index 460b2cc02b..6664281abe 100644
--- a/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts
@@ -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();
diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts
index 29f1e1c264..84e82b796e 100644
--- a/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts
+++ b/superset-frontend/cypress-base/cypress/integration/dashboard/utils.ts
@@ -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() {
diff --git a/superset-frontend/src/components/FaveStar/FaveStar.test.tsx b/superset-frontend/src/components/FaveStar/FaveStar.test.tsx
index ab2fa9fa0e..1ecb372dd0 100644
--- a/superset-frontend/src/components/FaveStar/FaveStar.test.tsx
+++ b/superset-frontend/src/components/FaveStar/FaveStar.test.tsx
@@ -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);
 });
diff --git a/superset-frontend/src/components/FaveStar/index.tsx b/superset-frontend/src/components/FaveStar/index.tsx
index c7b605243a..8b5d3ee89a 100644
--- a/superset-frontend/src/components/FaveStar/index.tsx
+++ b/superset-frontend/src/components/FaveStar/index.tsx
@@ -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) => {
diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js
index 058b3700bf..a166e5d4db 100644
--- a/superset-frontend/src/dashboard/actions/dashboardState.js
+++ b/superset-frontend/src/dashboard/actions/dashboardState.js
@@ -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));
       })
diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
index 79b242a179..90790872a2 100644
--- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx
+++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx
@@ -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 (
     <>
diff --git a/superset-frontend/src/explore/actions/exploreActions.ts b/superset-frontend/src/explore/actions/exploreActions.ts
index 0e13499b14..36300b4a12 100644
--- a/superset-frontend/src/explore/actions/exploreActions.ts
+++ b/superset-frontend/src/explore/actions/exploreActions.ts
@@ -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(
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
index a4ad594387..4f3b4d8dd0 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/ExploreViewContainer.test.tsx
@@ -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 = ({
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index 6812d1e0c5..dfcf23e190 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -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 =>
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 5b453a2d99..f9ccc04e1e 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -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
diff --git a/superset/charts/dao.py b/superset/charts/dao.py
index 384bd9a1fe..7102e6ad23 100644
--- a/superset/charts/dao.py
+++ b/superset/charts/dao.py
@@ -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()
diff --git a/superset/constants.py b/superset/constants.py
index 7007e77e3f..5a1679d6db 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -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",
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 580fc8bc8a..a252cff000 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -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
diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py
index c98abee1f0..a51ddbb92c 100644
--- a/superset/dashboards/dao.py
+++ b/superset/dashboards/dao.py
@@ -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()
diff --git a/superset/views/core.py b/superset/views/core.py
index 44f1b78af0..f2dfa5d140 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -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:
diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py
index 38fa1b7a6c..e3a5859886 100644
--- a/tests/integration_tests/charts/api_tests.py
+++ b/tests/integration_tests/charts/api_tests.py
@@ -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
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index e5e7b42db7..6abc649b9a 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -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):
         """
diff --git a/tests/unit_tests/charts/dao/dao_tests.py b/tests/unit_tests/charts/dao/dao_tests.py
index 15310712a5..faec8694db 100644
--- a/tests/unit_tests/charts/dao/dao_tests.py
+++ b/tests/unit_tests/charts/dao/dao_tests.py
@@ -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
diff --git a/tests/unit_tests/dashboards/dao_tests.py b/tests/unit_tests/dashboards/dao_tests.py
new file mode 100644
index 0000000000..a8f93e7513
--- /dev/null
+++ b/tests/unit_tests/dashboards/dao_tests.py
@@ -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