diff --git a/superset-frontend/src/components/FaveStar.tsx b/superset-frontend/src/components/FaveStar.tsx index 378db09c1d..aeb4885135 100644 --- a/superset-frontend/src/components/FaveStar.tsx +++ b/superset-frontend/src/components/FaveStar.tsx @@ -23,7 +23,7 @@ import Icon from './Icon'; interface FaveStarProps { itemId: number; - fetchFaveStar(id: number): any; + fetchFaveStar?: (id: number) => void; saveFaveStar(id: number, isStarred: boolean): any; isStarred: boolean; showTooltip?: boolean; @@ -35,7 +35,9 @@ const StyledLink = styled.a` export default class FaveStar extends React.PureComponent { componentDidMount() { - this.props.fetchFaveStar(this.props.itemId); + if (this.props.fetchFaveStar) { + this.props.fetchFaveStar(this.props.itemId); + } } onClick = (e: React.MouseEvent) => { diff --git a/superset-frontend/src/components/ListViewCard/index.tsx b/superset-frontend/src/components/ListViewCard/index.tsx index 8849b4b001..b63f3afeaa 100644 --- a/superset-frontend/src/components/ListViewCard/index.tsx +++ b/superset-frontend/src/components/ListViewCard/index.tsx @@ -152,11 +152,9 @@ interface CardProps { coverLeft?: React.ReactNode; coverRight?: React.ReactNode; actions: React.ReactNode | null; - showImg?: boolean; rows?: number | string; avatar?: string; - isRecent?: boolean; - renderCover?: React.ReactNode | null; + cover?: React.ReactNode | null; } function ListViewCard({ @@ -167,42 +165,39 @@ function ListViewCard({ imgFallbackURL, description, coverLeft, - isRecent, coverRight, actions, avatar, loading, imgPosition = 'top', - renderCover, + cover, }: CardProps) { return ( - -
- -
-
- - {!loading && coverLeft && ( - {coverLeft} - )} - {!loading && coverRight && ( - {coverRight} - )} - - - ) - : null + cover || ( + + +
+ +
+
+ + {!loading && coverLeft && ( + {coverLeft} + )} + {!loading && coverRight && ( + {coverRight} + )} + +
+ ) } > {loading && ( diff --git a/superset-frontend/src/views/CRUD/chart/ChartCard.tsx b/superset-frontend/src/views/CRUD/chart/ChartCard.tsx index 5dd6bd8f79..3536e5aa22 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartCard.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartCard.tsx @@ -17,7 +17,6 @@ * under the License. */ import React from 'react'; -import { useFavoriteStatus } from 'src/views/CRUD/hooks'; import { t } from '@superset-ui/core'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import Icon from 'src/components/Icon'; @@ -30,8 +29,6 @@ import FaveStar from 'src/components/FaveStar'; import FacePile from 'src/components/FacePile'; import { handleChartDelete } from '../utils'; -const FAVESTAR_BASE_URL = '/superset/favstar/slice'; - interface ChartCardProps { chart: Chart; hasPerm: (perm: string) => boolean; @@ -41,6 +38,8 @@ interface ChartCardProps { addSuccessToast: (msg: string) => void; refreshData: () => void; loading: boolean; + saveFavoriteStatus: (id: number, isStarred: boolean) => void; + favoriteStatus: boolean; } export default function ChartCard({ @@ -52,14 +51,11 @@ export default function ChartCard({ addSuccessToast, refreshData, loading, + saveFavoriteStatus, + favoriteStatus, }: ChartCardProps) { const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); - const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus( - {}, - FAVESTAR_BASE_URL, - addDangerToast, - ); const menu = ( @@ -124,9 +120,8 @@ export default function ChartCard({ diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx index 91cb45873e..ed9953abb1 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx @@ -47,7 +47,6 @@ import TooltipWrapper from 'src/components/TooltipWrapper'; import ChartCard from './ChartCard'; const PAGE_SIZE = 25; -const FAVESTAR_BASE_URL = '/superset/favstar/slice'; const createFetchDatasets = (handleError: (err: Response) => void) => async ( filterValue = '', @@ -105,9 +104,12 @@ function ChartList(props: ChartListProps) { toggleBulkSelect, refreshData, } = useListViewResource('chart', t('chart'), props.addDangerToast); - const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus( - {}, - FAVESTAR_BASE_URL, + + const chartIds = useMemo(() => charts.map(c => c.id), [charts]); + + const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus( + 'chart', + chartIds, props.addDangerToast, ); const { @@ -140,17 +142,6 @@ function ChartList(props: ChartListProps) { ); } - function renderFaveStar(id: number) { - return ( - - ); - } - const columns = useMemo( () => [ { @@ -158,7 +149,13 @@ function ChartList(props: ChartListProps) { row: { original: { id }, }, - }: any) => renderFaveStar(id), + }: any) => ( + + ), Header: '', id: 'favorite', disableSortBy: true, @@ -303,7 +300,7 @@ function ChartList(props: ChartListProps) { hidden: !canEdit && !canDelete, }, ], - [canEdit, canDelete], + [canEdit, canDelete, favoriteStatus], ); const filters: Filters = [ @@ -415,6 +412,8 @@ function ChartList(props: ChartListProps) { addSuccessToast={props.addSuccessToast} refreshData={refreshData} loading={loading} + favoriteStatus={favoriteStatus[chart.id]} + saveFavoriteStatus={saveFavoriteStatus} /> ); } diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx index f8713d68ee..b19c726020 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx @@ -29,11 +29,21 @@ import Icon from 'src/components/Icon'; import Label from 'src/components/Label'; import FacePile from 'src/components/FacePile'; import FaveStar from 'src/components/FaveStar'; -import { DashboardCardProps } from 'src/views/CRUD/types'; +import { Dashboard } from 'src/views/CRUD/types'; -import { useFavoriteStatus } from 'src/views/CRUD/hooks'; - -const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard'; +export interface DashboardCardProps { + isChart?: boolean; + dashboard: Dashboard; + hasPerm: (name: string) => boolean; + bulkSelectEnabled: boolean; + refreshData: () => void; + loading: boolean; + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + openDashboardEditModal?: (d: Dashboard) => void; + saveFavoriteStatus: (id: number, isStarred: boolean) => void; + favoriteStatus: boolean; +} function DashboardCard({ dashboard, @@ -43,15 +53,12 @@ function DashboardCard({ addDangerToast, addSuccessToast, openDashboardEditModal, + favoriteStatus, + saveFavoriteStatus, }: DashboardCardProps) { const canEdit = hasPerm('can_edit'); const canDelete = hasPerm('can_delete'); const canExport = hasPerm('can_mulexport'); - const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus( - {}, - FAVESTAR_BASE_URL, - addDangerToast, - ); const menu = ( @@ -123,16 +130,14 @@ function DashboardCard({ } - showImg /> ); } diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index d8acca3edf..7e2e6ae6cc 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -42,7 +42,6 @@ import Dashboard from 'src/dashboard/containers/Dashboard'; import DashboardCard from './DashboardCard'; const PAGE_SIZE = 25; -const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard'; interface DashboardListProps { addDangerToast: (msg: string) => void; @@ -81,9 +80,11 @@ function DashboardList(props: DashboardListProps) { t('dashboard'), props.addDangerToast, ); - const [favoriteStatusRef, fetchFaveStar, saveFaveStar] = useFavoriteStatus( - {}, - FAVESTAR_BASE_URL, + + const dashboardIds = useMemo(() => dashboards.map(d => d.id), [dashboards]); + const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus( + 'dashboard', + dashboardIds, props.addDangerToast, ); const [dashboardToEdit, setDashboardToEdit] = useState( @@ -140,17 +141,6 @@ function DashboardList(props: DashboardListProps) { ); } - function renderFaveStar(id: number) { - return ( - - ); - } - const columns = useMemo( () => [ { @@ -158,7 +148,13 @@ function DashboardList(props: DashboardListProps) { row: { original: { id }, }, - }: any) => renderFaveStar(id), + }: any) => ( + + ), Header: '', id: 'favorite', disableSortBy: true, @@ -317,7 +313,7 @@ function DashboardList(props: DashboardListProps) { disableSortBy: true, }, ], - [canEdit, canDelete, canExport, favoriteStatusRef], + [canEdit, canDelete, canExport, favoriteStatus], ); const filters: Filters = [ @@ -404,15 +400,16 @@ function DashboardList(props: DashboardListProps) { function renderCard(dashboard: Dashboard) { return ( ); } diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index b083bde776..2373a540a2 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -17,8 +17,8 @@ * under the License. */ import rison from 'rison'; -import { useState, useEffect, useCallback, useRef } from 'react'; -import { SupersetClient, t } from '@superset-ui/core'; +import { useState, useEffect, useCallback } from 'react'; +import { makeApi, SupersetClient, t } from '@superset-ui/core'; import { createErrorHandler } from 'src/views/CRUD/utils'; import { FetchDataConfig } from 'src/components/ListView'; @@ -58,7 +58,9 @@ export function useListViewResource( } useEffect(() => { - const infoParam = infoEnable ? '_info?q=(keys:!(permissions))' : ''; + const infoParam = infoEnable + ? `_info?q=${rison.encode({ keys: ['permissions'] })}` + : ''; SupersetClient.get({ endpoint: `/api/v1/${resource}/${infoParam}`, }).then( @@ -299,32 +301,52 @@ export function useSingleViewResource( }; } -// the hooks api has some known limitations around stale state in closures. -// See https://github.com/reactjs/rfcs/blob/master/text/0068-react-hooks.md#drawbacks -// the useRef hook is a way of getting around these limitations by having a consistent ref -// that points to the most recent value. +enum FavStarClassName { + CHART = 'slice', + DASHBOARD = 'Dashboard', +} + +type FavoriteStatusResponse = { + result: Array<{ + id: string; + value: boolean; + }>; +}; + +const favoriteApis = { + chart: makeApi({ + requestType: 'search', + method: 'GET', + endpoint: '/api/v1/chart/favorite_status', + }), + dashboard: makeApi({ + requestType: 'search', + method: 'GET', + endpoint: '/api/v1/dashboard/favorite_status', + }), +}; + export function useFavoriteStatus( - initialState: FavoriteStatus, - baseURL: string, + type: 'chart' | 'dashboard', + ids: Array, handleErrorMsg: (message: string) => void, ) { - const [favoriteStatus, setFavoriteStatus] = useState( - initialState, - ); - const favoriteStatusRef = useRef(favoriteStatus); - useEffect(() => { - favoriteStatusRef.current = favoriteStatus; - }); + const [favoriteStatus, setFavoriteStatus] = useState({}); const updateFavoriteStatus = (update: FavoriteStatus) => setFavoriteStatus(currentState => ({ ...currentState, ...update })); - const fetchFaveStar = (id: number) => { - SupersetClient.get({ - endpoint: `${baseURL}/${id}/count/`, - }).then( - ({ json }) => { - updateFavoriteStatus({ [id]: json.count > 0 }); + useEffect(() => { + if (!ids.length) { + return; + } + favoriteApis[type](`q=${rison.encode(ids)}`).then( + ({ result }) => { + const update = result.reduce((acc, element) => { + acc[element.id] = element.value; + return acc; + }, {}); + updateFavoriteStatus(update); }, createErrorHandler(errMsg => handleErrorMsg( @@ -332,31 +354,32 @@ export function useFavoriteStatus( ), ), ); - }; + }, [ids]); - const saveFaveStar = (id: number, isStarred: boolean) => { - const urlSuffix = isStarred ? 'unselect' : 'select'; - - SupersetClient.get({ - endpoint: `${baseURL}/${id}/${urlSuffix}/`, - }).then( - () => { - updateFavoriteStatus({ [id]: !isStarred }); - }, - createErrorHandler(errMsg => - handleErrorMsg( - t('There was an error saving the favorite status: %s', errMsg), + 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 }) => { + updateFavoriteStatus({ + [id]: (json as { count: number })?.count > 0, + }); + }, + createErrorHandler(errMsg => + handleErrorMsg( + t('There was an error saving the favorite status: %s', errMsg), + ), ), - ), - ); - }; + ); + }, + [type], + ); - return [ - favoriteStatusRef, - fetchFaveStar, - saveFaveStar, - favoriteStatus, - ] as const; + return [saveFaveStar, favoriteStatus] as const; } export const useChartEditModal = ( diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index 0946247534..39d0c81367 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -45,17 +45,6 @@ export interface Dashboard { loading?: boolean; } -export interface DashboardCardProps { - isChart?: boolean; - dashboard: Dashboard; - hasPerm: (name: string) => boolean; - bulkSelectEnabled: boolean; - refreshData: () => void; - addDangerToast: (msg: string) => void; - addSuccessToast: (msg: string) => void; - openDashboardEditModal?: (d: Dashboard) => void; -} - export type SavedQueryObject = { database: { database_name: string; diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index ee7cbebec3..1fc457fdd3 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -60,7 +60,7 @@ const createFetchResourceMethod = (method: string) => ( export const getRecentAcitivtyObjs = ( userId: string | number, recent: string, - addDangerToast: (arg0: string, arg1: string) => void, + addDangerToast: (arg1: string, arg2: any) => any, ) => { const getParams = (filters?: Array) => { const params = { diff --git a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx index ecaddf3fca..6a3387c460 100644 --- a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx @@ -177,8 +177,8 @@ export default function ActivityTable({ user }: ActivityProps) { return activityData[activeChild].map((e: ActivityObjects) => ( } url={e.sql ? `/supserset/sqllab?queryId=${e.id}` : e.url} title={getFilterTitle(e)} description={`Last Edited: ${moment(e.changed_on_utc).format( diff --git a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx index 9a12dfdb7f..feca46e464 100644 --- a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx @@ -16,9 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { t } from '@superset-ui/core'; -import { useListViewResource, useChartEditModal } from 'src/views/CRUD/hooks'; +import { + useListViewResource, + useChartEditModal, + useFavoriteStatus, +} from 'src/views/CRUD/hooks'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import PropertiesModal from 'src/explore/components/PropertiesModal'; import { User } from 'src/types/bootstrapTypes'; @@ -51,6 +55,12 @@ function ChartTable({ refreshData, fetchData, } = useListViewResource('chart', t('chart'), addDangerToast); + const chartIds = useMemo(() => charts.map(c => c.id), [charts]); + const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus( + 'chart', + chartIds, + addDangerToast, + ); const { sliceCurrentlyEditing, openChartEditModal, @@ -154,6 +164,8 @@ function ChartTable({ refreshData={refreshData} addDangerToast={addDangerToast} addSuccessToast={addSuccessToast} + favoriteStatus={favoriteStatus[e.id]} + saveFavoriteStatus={saveFavoriteStatus} /> ))} diff --git a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx index 67536cdc28..8643fc05a2 100644 --- a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx @@ -16,9 +16,9 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { SupersetClient, t } from '@superset-ui/core'; -import { useListViewResource } from 'src/views/CRUD/hooks'; +import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import { Dashboard, DashboardTableProps } from 'src/views/CRUD/types'; import withToasts from 'src/messageToasts/enhancers/withToasts'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; @@ -42,7 +42,7 @@ function DashboardTable({ addSuccessToast, }: DashboardTableProps) { const { - state: { loading, resourceCollection: dashboards, bulkSelectEnabled }, + state: { loading, resourceCollection: dashboards }, setResourceCollection: setDashboards, hasPerm, refreshData, @@ -52,7 +52,12 @@ function DashboardTable({ t('dashboard'), addDangerToast, ); - + const dashboardIds = useMemo(() => dashboards.map(c => c.id), [dashboards]); + const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus( + 'dashboard', + dashboardIds, + addDangerToast, + ); const [editModal, setEditModal] = useState(); const [dashboardFilter, setDashboardFilter] = useState('Mine'); @@ -168,16 +173,16 @@ function DashboardTable({ {dashboards.map(e => ( setEditModal(dashboard), - }} + dashboard={e} + hasPerm={hasPerm} + bulkSelectEnabled={false} + refreshData={refreshData} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + loading={loading} + openDashboardEditModal={dashboard => setEditModal(dashboard)} + saveFavoriteStatus={saveFavoriteStatus} + favoriteStatus={favoriteStatus[e.id]} /> ))} diff --git a/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx b/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx index dc0590bacb..f7b65094a5 100644 --- a/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx +++ b/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx @@ -227,8 +227,7 @@ const SavedQueries = ({ rows={q.rows} loading={loading} description={t('Last run ', q.end_time)} - showImg={false} - renderCover={ + cover={
{t('Tables')}
diff --git a/superset/charts/api.py b/superset/charts/api.py index 3ab4e4acf9..94dd3757f8 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -45,6 +45,7 @@ from superset.charts.commands.exceptions import ( ) from superset.charts.commands.export import ExportChartsCommand from superset.charts.commands.update import UpdateChartCommand +from superset.charts.dao import ChartDAO from superset.charts.filters import ChartAllTextFilter, ChartFavoriteFilter, ChartFilter from superset.charts.schemas import ( CHART_SCHEMAS, @@ -53,6 +54,7 @@ from superset.charts.schemas import ( ChartPutSchema, get_delete_ids_schema, get_export_ids_schema, + get_fav_star_ids_schema, openapi_spec_methods_override, screenshot_query_schema, thumbnail_query_schema, @@ -87,7 +89,7 @@ class ChartRestApi(BaseSupersetModelRestApi): RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined "data", - "viz_types", + "favorite_status", } class_permission_name = "SliceModelView" show_columns = [ @@ -176,6 +178,7 @@ class ChartRestApi(BaseSupersetModelRestApi): "screenshot_query_schema": screenshot_query_schema, "get_delete_ids_schema": get_delete_ids_schema, "get_export_ids_schema": get_export_ids_schema, + "get_fav_star_ids_schema": get_fav_star_ids_schema, } """ Add extra schemas to the OpenAPI components schema section """ openapi_spec_methods = openapi_spec_methods_override @@ -773,3 +776,48 @@ class ChartRestApi(BaseSupersetModelRestApi): as_attachment=True, attachment_filename=filename, ) + + @expose("/favorite_status/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @rison(get_fav_star_ids_schema) + def favorite_status(self, **kwargs: Any) -> Response: + """Favorite stars for Charts + --- + get: + description: >- + Check favorited dashboards for current user + parameters: + - in: query + name: q + content: + application/json: + schema: + $ref: '#/components/schemas/get_fav_star_ids_schema' + responses: + 200: + description: + content: + application/json: + schema: + $ref: "#/components/schemas/GetFavStarIdsSchema" + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + requested_ids = kwargs["rison"] + charts = ChartDAO.find_by_ids(requested_ids) + if not charts: + return self.response_404() + favorited_chart_ids = ChartDAO.favorited_ids(charts, g.user.id) + res = [ + {"id": request_id, "value": request_id in favorited_chart_ids} + for request_id in requested_ids + ] + return self.response(200, result=res) diff --git a/superset/charts/dao.py b/superset/charts/dao.py index cb59868bf5..2a80f82b85 100644 --- a/superset/charts/dao.py +++ b/superset/charts/dao.py @@ -22,6 +22,7 @@ from sqlalchemy.exc import SQLAlchemyError from superset.charts.filters import ChartFilter from superset.dao.base import BaseDAO from superset.extensions import db +from superset.models.core import FavStar, FavStarClassName from superset.models.slice import Slice if TYPE_CHECKING: @@ -66,3 +67,17 @@ class ChartDAO(BaseDAO): db.session.merge(slc) if commit: db.session.commit() + + @staticmethod + def favorited_ids(charts: List[Slice], current_user_id: int) -> List[FavStar]: + ids = [chart.id for chart in charts] + return [ + star.obj_id + for star in db.session.query(FavStar.obj_id) + .filter( + FavStar.class_name == FavStarClassName.CHART, + FavStar.obj_id.in_(ids), + FavStar.user_id == current_user_id, + ) + .all() + ] diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 60d468ab8f..026ad13fd8 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -47,6 +47,8 @@ screenshot_query_schema = { } get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_fav_star_ids_schema = {"type": "array", "items": {"type": "integer"}} + # # Column schema descriptions # @@ -1031,6 +1033,18 @@ class ChartDataResponseSchema(Schema): ) +class ChartFavStarResponseResult(Schema): + id = fields.Integer(description="The Chart id") + value = fields.Boolean(description="The FaveStar value") + + +class GetFavStarIdsSchema(Schema): + result = fields.List( + fields.Nested(ChartFavStarResponseResult), + description="A list of results for each corresponding chart in the request", + ) + + CHART_SCHEMAS = ( ChartDataQueryContextSchema, ChartDataResponseSchema, @@ -1049,4 +1063,5 @@ CHART_SCHEMAS = ( ChartDataGeodeticParseOptionsSchema, ChartGetDatasourceResponseSchema, ChartCacheScreenshotResponseSchema, + GetFavStarIdsSchema, ) diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index cba494a456..815bfd19fc 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -44,6 +44,7 @@ from superset.dashboards.commands.exceptions import ( ) from superset.dashboards.commands.export import ExportDashboardsCommand from superset.dashboards.commands.update import UpdateDashboardCommand +from superset.dashboards.dao import DashboardDAO from superset.dashboards.filters import ( DashboardFavoriteFilter, DashboardFilter, @@ -54,6 +55,8 @@ from superset.dashboards.schemas import ( DashboardPutSchema, get_delete_ids_schema, get_export_ids_schema, + get_fav_star_ids_schema, + GetFavStarIdsSchema, openapi_spec_methods_override, thumbnail_query_schema, ) @@ -78,6 +81,7 @@ class DashboardRestApi(BaseSupersetModelRestApi): RouteMethod.EXPORT, RouteMethod.RELATED, "bulk_delete", # not using RouteMethod since locally defined + "favorite_status", } resource_name = "dashboard" allow_browser_login = True @@ -181,10 +185,13 @@ class DashboardRestApi(BaseSupersetModelRestApi): allowed_rel_fields = {"owners", "created_by"} openapi_spec_tag = "Dashboards" + """ Override the name set for this collection of endpoints """ + openapi_spec_component_schemas = (GetFavStarIdsSchema,) apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, "get_export_ids_schema": get_export_ids_schema, "thumbnail_query_schema": thumbnail_query_schema, + "get_fav_star_ids_schema": get_fav_star_ids_schema, } openapi_spec_methods = openapi_spec_methods_override """ Overrides GET methods OpenApi descriptions """ @@ -589,3 +596,48 @@ class DashboardRestApi(BaseSupersetModelRestApi): return Response( FileWrapper(screenshot), mimetype="image/png", direct_passthrough=True ) + + @expose("/favorite_status/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @rison(get_fav_star_ids_schema) + def favorite_status(self, **kwargs: Any) -> Response: + """Favorite Stars for Dashboards + --- + get: + description: >- + Check favorited dashboards for current user + parameters: + - in: query + name: q + content: + application/json: + schema: + $ref: '#/components/schemas/get_fav_star_ids_schema' + responses: + 200: + description: + content: + application/json: + schema: + $ref: "#/components/schemas/GetFavStarIdsSchema" + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + requested_ids = kwargs["rison"] + dashboards = DashboardDAO.find_by_ids(requested_ids) + if not dashboards: + return self.response_404() + favorited_dashboard_ids = DashboardDAO.favorited_ids(dashboards, g.user.id) + res = [ + {"id": request_id, "value": request_id in favorited_dashboard_ids} + for request_id in requested_ids + ] + return self.response(200, result=res) diff --git a/superset/dashboards/dao.py b/superset/dashboards/dao.py index 35db3318f3..65bdc690b1 100644 --- a/superset/dashboards/dao.py +++ b/superset/dashboards/dao.py @@ -23,6 +23,7 @@ from sqlalchemy.exc import SQLAlchemyError from superset.dao.base import BaseDAO from superset.dashboards.filters import DashboardFilter from superset.extensions import db +from superset.models.core import FavStar, FavStarClassName from superset.models.dashboard import Dashboard from superset.models.slice import Slice from superset.utils.dashboard_filter_scopes_converter import copy_filter_scopes @@ -154,3 +155,19 @@ class DashboardDAO(BaseDAO): if data.get("label_colors"): md["label_colors"] = data.get("label_colors") dashboard.json_metadata = json.dumps(md) + + @staticmethod + def favorited_ids( + dashboards: List[Dashboard], current_user_id: int + ) -> List[FavStar]: + ids = [dash.id for dash in dashboards] + return [ + star.obj_id + for star in db.session.query(FavStar.obj_id) + .filter( + FavStar.class_name == FavStarClassName.DASHBOARD, + FavStar.obj_id.in_(ids), + FavStar.user_id == current_user_id, + ) + .all() + ] diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 848a80c6e3..b6bbc3748c 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -26,6 +26,7 @@ from superset.utils import core as utils get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} get_export_ids_schema = {"type": "array", "items": {"type": "integer"}} +get_fav_star_ids_schema = {"type": "array", "items": {"type": "integer"}} thumbnail_query_schema = { "type": "object", "properties": {"force": {"type": "boolean"}}, @@ -163,3 +164,15 @@ class DashboardPutSchema(BaseDashboardSchema): validate=validate_json_metadata, ) published = fields.Boolean(description=published_description, allow_none=True) + + +class ChartFavStarResponseResult(Schema): + id = fields.Integer(description="The Chart id") + value = fields.Boolean(description="The FaveStar value") + + +class GetFavStarIdsSchema(Schema): + result = fields.List( + fields.Nested(ChartFavStarResponseResult), + description="A list of results for each corresponding chart in the request", + ) diff --git a/superset/models/core.py b/superset/models/core.py index 6d8a29cce2..952ba83bba 100755 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -22,6 +22,7 @@ import textwrap from contextlib import closing from copy import deepcopy from datetime import datetime +from enum import Enum from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type import numpy @@ -707,6 +708,11 @@ class Log(Model): # pylint: disable=too-few-public-methods referrer = Column(String(1024)) +class FavStarClassName(str, Enum): + CHART = "slice" + DASHBOARD = "Dashboard" + + class FavStar(Model): # pylint: disable=too-few-public-methods __tablename__ = "favstar" diff --git a/tests/charts/api_tests.py b/tests/charts/api_tests.py index 429d26c414..00decdc23d 100644 --- a/tests/charts/api_tests.py +++ b/tests/charts/api_tests.py @@ -35,7 +35,7 @@ from tests.fixtures.unicode_dashboard import load_unicode_dashboard_with_slice 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.core import FavStar, FavStarClassName from superset.models.dashboard import Dashboard from superset.models.slice import Slice from superset.utils import core as utils @@ -776,6 +776,35 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin): assert rv.status_code == 200 assert len(expected_models) == data["count"] + @pytest.mark.usefixtures("create_charts") + def test_get_current_user_favorite_status(self): + """ + Dataset API: Test get current user favorite stars + """ + admin = self.get_user("admin") + users_favorite_ids = [ + star.obj_id + for star in db.session.query(FavStar.obj_id) + .filter( + and_( + FavStar.user_id == admin.id, + FavStar.class_name == FavStarClassName.CHART, + ) + ) + .all() + ] + + assert users_favorite_ids + arguments = [s.id for s in db.session.query(Slice.id).all()] + self.login(username="admin") + uri = f"api/v1/chart/favorite_status/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + for res in data["result"]: + if res["id"] in users_favorite_ids: + assert res["value"] + @pytest.mark.usefixtures("load_unicode_dashboard_with_slice") def test_get_charts_page(self): """ diff --git a/tests/dashboards/api_tests.py b/tests/dashboards/api_tests.py index 7df1176d50..162ea22884 100644 --- a/tests/dashboards/api_tests.py +++ b/tests/dashboards/api_tests.py @@ -31,7 +31,7 @@ from freezegun import freeze_time 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.core import FavStar, FavStarClassName from superset.models.slice import Slice from superset.views.base import generate_download_headers @@ -340,6 +340,35 @@ class TestDashboardApi(SupersetTestCase, ApiOwnersTestCaseMixin): expected_model.dashboard_title == data["result"][i]["dashboard_title"] ) + @pytest.mark.usefixtures("create_dashboards") + def test_get_current_user_favorite_status(self): + """ + Dataset API: Test get current user favorite stars + """ + admin = self.get_user("admin") + users_favorite_ids = [ + star.obj_id + for star in db.session.query(FavStar.obj_id) + .filter( + and_( + FavStar.user_id == admin.id, + FavStar.class_name == FavStarClassName.DASHBOARD, + ) + ) + .all() + ] + + assert users_favorite_ids + arguments = [dash.id for dash in db.session.query(Dashboard.id).all()] + self.login(username="admin") + uri = f"api/v1/dashboard/favorite_status/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 200 + for res in data["result"]: + if res["id"] in users_favorite_ids: + assert res["value"] + @pytest.mark.usefixtures("create_dashboards") def test_get_dashboards_not_favorite_filter(self): """