diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 524d5a6d1c14f..c869874be86b2 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -61,6 +61,7 @@ export enum FeatureFlag { SQL_VALIDATORS_BY_ENGINE = 'SQL_VALIDATORS_BY_ENGINE', THUMBNAILS = 'THUMBNAILS', USE_ANALAGOUS_COLORS = 'USE_ANALAGOUS_COLORS', + TAGGING_SYSTEM = 'TAGGING_SYSTEM', UX_BETA = 'UX_BETA', VERSIONED_EXPORT = 'VERSIONED_EXPORT', SSH_TUNNELING = 'SSH_TUNNELING', diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 641cb05801515..0c1314f2637c6 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -118,4 +118,7 @@ export enum FilterOperator { datasetIsCertified = 'dataset_is_certified', dashboardHasCreatedBy = 'dashboard_has_created_by', chartHasCreatedBy = 'chart_has_created_by', + dashboardTags = 'dashboard_tags', + chartTags = 'chart_tags', + savedQueryTags = 'saved_query_tags', } diff --git a/superset-frontend/src/components/Tags/Tag.test.tsx b/superset-frontend/src/components/Tags/Tag.test.tsx new file mode 100644 index 0000000000000..0ff7b2e85a3f3 --- /dev/null +++ b/superset-frontend/src/components/Tags/Tag.test.tsx @@ -0,0 +1,35 @@ +/** + * 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. + */ +import React from 'react'; +import { render } from 'spec/helpers/testing-library'; +import TagType from 'src/types/TagType'; +import Tag from './Tag'; + +const mockedProps: TagType = { + name: 'example-tag', + id: 1, + onDelete: undefined, + editable: false, + onClick: undefined, +}; + +test('should render', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/Tags/Tag.tsx b/superset-frontend/src/components/Tags/Tag.tsx new file mode 100644 index 0000000000000..ecd2cb135a0b6 --- /dev/null +++ b/superset-frontend/src/components/Tags/Tag.tsx @@ -0,0 +1,86 @@ +/** + * 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. + */ + +import { styled } from '@superset-ui/core'; +import TagType from 'src/types/TagType'; +import AntdTag from 'antd/lib/tag'; +import React, { useMemo } from 'react'; +import { Tooltip } from 'src/components/Tooltip'; + +const StyledTag = styled(AntdTag)` + ${({ theme }) => ` + margin-top: ${theme.gridUnit}px; + margin-bottom: ${theme.gridUnit}px; + font-size: ${theme.typography.sizes.s}px; + `}; +`; + +const Tag = ({ + name, + id, + index = undefined, + onDelete = undefined, + editable = false, + onClick = undefined, +}: TagType) => { + const isLongTag = useMemo(() => name.length > 20, [name]); + + const handleClose = () => (index ? onDelete?.(index) : null); + + const tagElem = ( + <> + {editable ? ( + + {isLongTag ? `${name.slice(0, 20)}...` : name} + + ) : ( + + {id ? ( + + {isLongTag ? `${name.slice(0, 20)}...` : name} + + ) : isLongTag ? ( + `${name.slice(0, 20)}...` + ) : ( + name + )} + + )} + + ); + + return isLongTag ? ( + + {tagElem} + + ) : ( + tagElem + ); +}; + +export default Tag; diff --git a/superset-frontend/src/components/Tags/TagsList.stories.tsx b/superset-frontend/src/components/Tags/TagsList.stories.tsx new file mode 100644 index 0000000000000..0bfe27b42a17a --- /dev/null +++ b/superset-frontend/src/components/Tags/TagsList.stories.tsx @@ -0,0 +1,58 @@ +/** + * 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. + */ +import React from 'react'; +import TagType from 'src/types/TagType'; +import { TagsList } from '.'; +import { TagsListProps } from './TagsList'; + +export default { + title: 'Tags', + component: TagsList, +}; + +export const InteractiveTags = ({ tags, editable, maxTags }: TagsListProps) => ( + +); + +const tags: TagType[] = [ + { name: 'tag1' }, + { name: 'tag2' }, + { name: 'tag3' }, + { name: 'tag4' }, + { name: 'tag5' }, + { name: 'tag6' }, +]; + +const editable = true; + +const maxTags = 3; + +InteractiveTags.args = { + tags, + editable, + maxTags, +}; + +InteractiveTags.story = { + parameters: { + knobs: { + disable: true, + }, + }, +}; diff --git a/superset-frontend/src/components/Tags/TagsList.test.tsx b/superset-frontend/src/components/Tags/TagsList.test.tsx new file mode 100644 index 0000000000000..f67dbce294663 --- /dev/null +++ b/superset-frontend/src/components/Tags/TagsList.test.tsx @@ -0,0 +1,78 @@ +/** + * 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. + */ +import React from 'react'; +import { render, waitFor } from 'spec/helpers/testing-library'; +import TagsList, { TagsListProps } from './TagsList'; + +const testTags = [ + { + name: 'example-tag1', + id: 1, + }, + { + name: 'example-tag2', + id: 2, + }, + { + name: 'example-tag3', + id: 3, + }, + { + name: 'example-tag4', + id: 4, + }, + { + name: 'example-tag5', + id: 5, + }, +]; + +const mockedProps: TagsListProps = { + tags: testTags, + onDelete: undefined, + maxTags: 5, +}; + +const getElementsByClassName = (className: string) => + document.querySelectorAll(className)! as NodeListOf; + +const findAllTags = () => waitFor(() => getElementsByClassName('.ant-tag')); + +test('should render', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); +}); + +test('should render 5 elements', async () => { + render(); + const tagsListItems = await findAllTags(); + expect(tagsListItems).toHaveLength(5); + expect(tagsListItems[0]).toHaveTextContent(testTags[0].name); + expect(tagsListItems[1]).toHaveTextContent(testTags[1].name); + expect(tagsListItems[2]).toHaveTextContent(testTags[2].name); + expect(tagsListItems[3]).toHaveTextContent(testTags[3].name); + expect(tagsListItems[4]).toHaveTextContent(testTags[4].name); +}); + +test('should render 3 elements when maxTags is set to 3', async () => { + render(); + const tagsListItems = await findAllTags(); + expect(tagsListItems).toHaveLength(3); + expect(tagsListItems[2]).toHaveTextContent('+3...'); +}); diff --git a/superset-frontend/src/components/Tags/TagsList.tsx b/superset-frontend/src/components/Tags/TagsList.tsx new file mode 100644 index 0000000000000..102e6d2ced31f --- /dev/null +++ b/superset-frontend/src/components/Tags/TagsList.tsx @@ -0,0 +1,112 @@ +/** + * 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. + */ + +import React, { useMemo, useState } from 'react'; +import { styled } from '@superset-ui/core'; +import TagType from 'src/types/TagType'; +import Tag from './Tag'; + +export type TagsListProps = { + tags: TagType[]; + editable?: boolean; + /** + * OnDelete: + * Only applies when editable is true + * Callback for when a tag is deleted + */ + onDelete?: ((index: number) => void) | undefined; + maxTags?: number | undefined; +}; + +const TagsDiv = styled.div` + max-width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; +`; + +const TagsList = ({ + tags, + editable = false, + onDelete, + maxTags, +}: TagsListProps) => { + const [tempMaxTags, setTempMaxTags] = useState(maxTags); + + const handleDelete = (index: number) => { + onDelete?.(index); + }; + + const expand = () => setTempMaxTags(undefined); + + const collapse = () => setTempMaxTags(maxTags); + + const tagsIsLong: boolean | null = useMemo( + () => (tempMaxTags ? tags.length > tempMaxTags : null), + [tags.length, tempMaxTags], + ); + + const extraTags: number | null = useMemo( + () => + typeof tempMaxTags === 'number' ? tags.length - tempMaxTags + 1 : null, + [tagsIsLong, tags.length, tempMaxTags], + ); + + return ( + + {tagsIsLong && typeof tempMaxTags === 'number' ? ( + <> + {tags.slice(0, tempMaxTags - 1).map((tag: TagType, index) => ( + + ))} + {tags.length > tempMaxTags ? ( + + ) : null} + + ) : ( + <> + {tags.map((tag: TagType, index) => ( + + ))} + {maxTags ? ( + tags.length > maxTags ? ( + + ) : null + ) : null} + + )} + + ); +}; + +export default TagsList; diff --git a/superset-frontend/src/components/Tags/index.tsx b/superset-frontend/src/components/Tags/index.tsx new file mode 100644 index 0000000000000..d9178e7a26f23 --- /dev/null +++ b/superset-frontend/src/components/Tags/index.tsx @@ -0,0 +1,21 @@ +/** + * 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. + */ + +export { default as TagsList } from './TagsList'; +export { default as Tag } from './Tag'; diff --git a/superset-frontend/src/components/Tags/utils.tsx b/superset-frontend/src/components/Tags/utils.tsx new file mode 100644 index 0000000000000..690a9b44066d0 --- /dev/null +++ b/superset-frontend/src/components/Tags/utils.tsx @@ -0,0 +1,93 @@ +/** + * 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. + */ + +import { SupersetClient, t } from '@superset-ui/core'; +import Tag from 'src/types/TagType'; + +import rison from 'rison'; +import { cacheWrapper } from 'src/utils/cacheWrapper'; +import { + ClientErrorObject, + getClientErrorObject, +} from 'src/utils/getClientErrorObject'; + +const localCache = new Map(); + +const cachedSupersetGet = cacheWrapper( + SupersetClient.get, + localCache, + ({ endpoint }) => endpoint || '', +); + +type SelectTagsValue = { + value: string | number | undefined; + label: string; + key: string | number | undefined; +}; + +export const tagToSelectOption = ( + item: Tag & { table_name: string }, +): SelectTagsValue => ({ + value: item.name, + label: item.name, + key: item.name, +}); + +export const loadTags = async ( + search: string, + page: number, + pageSize: number, +) => { + const searchColumn = 'name'; + const query = rison.encode({ + filters: [{ col: searchColumn, opr: 'ct', value: search }], + page, + page_size: pageSize, + order_column: searchColumn, + order_direction: 'asc', + }); + + const getErrorMessage = ({ error, message }: ClientErrorObject) => { + let errorText = message || error || t('An error has occurred'); + if (message === 'Forbidden') { + errorText = t('You do not have permission to edit this dashboard'); + } + return errorText; + }; + + return cachedSupersetGet({ + endpoint: `/api/v1/tag/?q=${query}`, + }) + .then(response => { + const data: { + label: string; + value: string | number; + }[] = response.json.result + .filter((item: Tag & { table_name: string }) => item.type === 1) + .map(tagToSelectOption); + return { + data, + totalCount: response.json.count, + }; + }) + .catch(async error => { + const errorMessage = getErrorMessage(await getClientErrorObject(error)); + throw new Error(errorMessage); + }); +}; diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index 770cc67d70e37..1c87abe7868b1 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -529,15 +529,18 @@ class Header extends React.PureComponent { isStarred: this.props.isStarred, showTooltip: true, }} - titlePanelAdditionalItems={ - - } + titlePanelAdditionalItems={[ + !editMode && ( + + ), + ]} rightPanelAdditionalItems={
{userCanSaveAs && ( @@ -684,7 +687,7 @@ class Header extends React.PureComponent { /> } showFaveStar={user?.userId && dashboardInfo?.id} - showTitlePanelItems={!editMode} + showTitlePanelItems /> {this.state.showingPropertiesModal && ( { expect( screen.getByRole('heading', { name: 'Certification' }), ).toBeInTheDocument(); - expect(screen.getAllByRole('heading')).toHaveLength(4); + // Tags will be included since isFeatureFlag always returns true in this test + expect(screen.getByRole('heading', { name: 'Tags' })).toBeInTheDocument(); + expect(screen.getAllByRole('heading')).toHaveLength(5); expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Advanced' })).toBeInTheDocument(); @@ -226,7 +228,7 @@ test('should render - FeatureFlag enabled', async () => { expect(screen.getAllByRole('button')).toHaveLength(4); expect(screen.getAllByRole('textbox')).toHaveLength(4); - expect(screen.getAllByRole('combobox')).toHaveLength(2); + expect(screen.getAllByRole('combobox')).toHaveLength(3); expect(spyColorSchemeControlWrapper).toBeCalledWith( expect.objectContaining({ colorScheme: 'supersetColors' }), @@ -245,10 +247,10 @@ test('should open advance', async () => { ).toBeInTheDocument(); expect(screen.getAllByRole('textbox')).toHaveLength(4); - expect(screen.getAllByRole('combobox')).toHaveLength(2); + expect(screen.getAllByRole('combobox')).toHaveLength(3); userEvent.click(screen.getByRole('button', { name: 'Advanced' })); expect(screen.getAllByRole('textbox')).toHaveLength(5); - expect(screen.getAllByRole('combobox')).toHaveLength(2); + expect(screen.getAllByRole('combobox')).toHaveLength(3); }); test('should close modal', async () => { @@ -382,7 +384,7 @@ test('should show all roles', async () => { useRedux: true, }); - expect(screen.getAllByRole('combobox')).toHaveLength(2); + expect(screen.getAllByRole('combobox')).toHaveLength(3); expect( screen.getByRole('combobox', { name: SupersetCore.t('Roles') }), ).toBeInTheDocument(); @@ -415,7 +417,7 @@ test('should show active owners with dashboard rbac', async () => { useRedux: true, }); - expect(screen.getAllByRole('combobox')).toHaveLength(2); + expect(screen.getAllByRole('combobox')).toHaveLength(3); expect( screen.getByRole('combobox', { name: SupersetCore.t('Owners') }), ).toBeInTheDocument(); diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index 59eca184085d8..b12102d324247 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Input } from 'src/components/Input'; import { FormItem } from 'src/components/Form'; import jsonStringify from 'json-stringify-pretty-compact'; @@ -41,6 +41,9 @@ import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeMo import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import withToasts from 'src/components/MessageToasts/withToasts'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; +import TagType from 'src/types/TagType'; +import { addTag, deleteTaggedObjects, fetchTags, OBJECT_TYPES } from 'src/tags'; +import { loadTags } from 'src/components/Tags/utils'; const StyledFormItem = styled(FormItem)` margin-bottom: 0; @@ -101,8 +104,18 @@ const PropertiesModal = ({ const [owners, setOwners] = useState([]); const [roles, setRoles] = useState([]); const saveLabel = onlyApply ? t('Apply') : t('Save'); + const [tags, setTags] = useState([]); const categoricalSchemeRegistry = getCategoricalSchemeRegistry(); + const tagsAsSelectValues = useMemo(() => { + const selectTags = tags.map(tag => ({ + value: tag.name, + label: tag.name, + key: tag.name, + })); + return selectTags; + }, [tags.length]); + const handleErrorResponse = async (response: Response) => { const { error, statusText, message } = await getClientErrorObject(response); let errorText = error || statusText || t('An error has occurred'); @@ -293,6 +306,41 @@ const PropertiesModal = ({ setColorScheme(colorScheme); }; + const updateTags = (oldTags: TagType[], newTags: TagType[]) => { + // update the tags for this object + // add tags that are in new tags, but not in old tags + // eslint-disable-next-line array-callback-return + newTags.map((tag: TagType) => { + if (!oldTags.some(t => t.name === tag.name)) { + addTag( + { + objectType: OBJECT_TYPES.DASHBOARD, + objectId: dashboardId, + includeTypes: false, + }, + tag.name, + () => {}, + () => {}, + ); + } + }); + // delete tags that are in old tags, but not in new tags + // eslint-disable-next-line array-callback-return + oldTags.map((tag: TagType) => { + if (!newTags.some(t => t.name === tag.name)) { + deleteTaggedObjects( + { + objectType: OBJECT_TYPES.DASHBOARD, + objectId: dashboardId, + }, + tag, + () => {}, + () => {}, + ); + } + }); + }; + const onFinish = () => { const { title, slug, certifiedBy, certificationDetails } = form.getFieldsValue(); @@ -350,6 +398,25 @@ const PropertiesModal = ({ updateMetadata: false, }); + if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) { + // update tags + try { + fetchTags( + { + objectType: OBJECT_TYPES.DASHBOARD, + objectId: dashboardId, + includeTypes: false, + }, + (currentTags: TagType[]) => updateTags(currentTags, tags), + error => { + handleErrorResponse(error); + }, + ); + } catch (error) { + handleErrorResponse(error); + } + } + const moreOnSubmitProps: { roles?: Roles } = {}; const morePutProps: { roles?: number[] } = {}; if (isFeatureEnabled(FeatureFlag.DASHBOARD_RBAC)) { @@ -454,6 +521,7 @@ const PropertiesModal = ({ { + if (!isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) return; + try { + fetchTags( + { + objectType: OBJECT_TYPES.DASHBOARD, + objectId: dashboardId, + includeTypes: false, + }, + (tags: TagType[]) => setTags(tags), + (error: Response) => { + addDangerToast(`Error fetching tags: ${error.text}`); + }, + ); + } catch (error) { + handleErrorResponse(error); + } + }, [dashboardId]); + + const handleChangeTags = (values: { label: string; value: number }[]) => { + // triggered whenever a new tag is selected or a tag was deselected + // on new tag selected, add the tag + + const uniqueTags = [...new Set(values.map(v => v.label))]; + setTags([...uniqueTags.map(t => ({ name: t }))]); + }; + return ( + {isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) ? ( + + +

{t('Tags')}

+ +
+ ) : null} + {isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) ? ( + + + + + +

+ {t('A list of tags that have been applied to this chart.')} +

+ +
+ ) : null}

diff --git a/superset-frontend/src/explore/components/PropertiesModal/index.tsx b/superset-frontend/src/explore/components/PropertiesModal/index.tsx index 53bcfe77c3d10..9465fc87b9184 100644 --- a/superset-frontend/src/explore/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/explore/components/PropertiesModal/index.tsx @@ -23,10 +23,19 @@ import Button from 'src/components/Button'; import { AsyncSelect, Row, Col, AntdForm } from 'src/components'; import { SelectValue } from 'antd/lib/select'; import rison from 'rison'; -import { t, SupersetClient, styled } from '@superset-ui/core'; +import { + t, + SupersetClient, + styled, + isFeatureEnabled, + FeatureFlag, +} from '@superset-ui/core'; import Chart, { Slice } from 'src/types/Chart'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import withToasts from 'src/components/MessageToasts/withToasts'; +import { loadTags } from 'src/components/Tags/utils'; +import { addTag, deleteTaggedObjects, fetchTags, OBJECT_TYPES } from 'src/tags'; +import TagType from 'src/types/TagType'; export type PropertiesModalProps = { slice: Slice; @@ -63,6 +72,17 @@ function PropertiesModal({ null, ); + const [tags, setTags] = useState([]); + + const tagsAsSelectValues = useMemo(() => { + const selectTags = tags.map(tag => ({ + value: tag.name, + label: tag.name, + key: tag.name, + })); + return selectTags; + }, [tags.length]); + function showError({ error, statusText, message }: any) { let errorText = error || statusText || t('An error has occurred'); if (message === 'Forbidden') { @@ -119,6 +139,41 @@ function PropertiesModal({ [], ); + const updateTags = (oldTags: TagType[], newTags: TagType[]) => { + // update the tags for this object + // add tags that are in new tags, but not in old tags + // eslint-disable-next-line array-callback-return + newTags.map((tag: TagType) => { + if (!oldTags.some(t => t.name === tag.name)) { + addTag( + { + objectType: OBJECT_TYPES.CHART, + objectId: slice.slice_id, + includeTypes: false, + }, + tag.name, + () => {}, + () => {}, + ); + } + }); + // delete tags that are in old tags, but not in new tags + // eslint-disable-next-line array-callback-return + oldTags.map((tag: TagType) => { + if (!newTags.some(t => t.name === tag.name)) { + deleteTaggedObjects( + { + objectType: OBJECT_TYPES.CHART, + objectId: slice.slice_id, + }, + tag, + () => {}, + () => {}, + ); + } + }); + }; + const onSubmit = async (values: { certified_by?: string; certification_details?: string; @@ -148,6 +203,25 @@ function PropertiesModal({ }[] ).map(o => o.value); } + if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) { + // update tags + try { + fetchTags( + { + objectType: OBJECT_TYPES.CHART, + objectId: slice.slice_id, + includeTypes: false, + }, + (currentTags: TagType[]) => updateTags(currentTags, tags), + error => { + showError(error); + }, + ); + } catch (error) { + showError(error); + } + } + try { const res = await SupersetClient.put({ endpoint: `/api/v1/chart/${slice.slice_id}`, @@ -158,6 +232,7 @@ function PropertiesModal({ const updatedChart = { ...payload, ...res.json.result, + tags, id: slice.slice_id, owners: selectedOwners, }; @@ -183,6 +258,37 @@ function PropertiesModal({ setName(slice.slice_name || ''); }, [slice.slice_name]); + useEffect(() => { + if (!isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) return; + try { + fetchTags( + { + objectType: OBJECT_TYPES.CHART, + objectId: slice.slice_id, + includeTypes: false, + }, + (tags: TagType[]) => setTags(tags), + error => { + showError(error); + }, + ); + } catch (error) { + showError(error); + } + }, [slice.slice_id]); + + const handleChangeTags = (values: { label: string; value: number }[]) => { + // triggered whenever a new tag is selected or a tag was deselected + // on new tag selected, add the tag + + const uniqueTags = [...new Set(values.map(v => v.label))]; + setTags([...uniqueTags.map(t => ({ name: t }))]); + }; + + const handleClearTags = () => { + setTags([]); + }; + return ( + {isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && ( +

{t('Tags')}

+ )} + {isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM) && ( + + + + {t('A list of tags that have been applied to this chart.')} + + + )} diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index 38271e470db40..e02d848a5dcfd 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -41,6 +41,7 @@ import { } from 'src/views/CRUD/hooks'; import handleResourceExport from 'src/utils/export'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import { TagsList } from 'src/components/Tags'; import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; import FaveStar from 'src/components/FaveStar'; import { Link, useHistory } from 'react-router-dom'; @@ -58,6 +59,7 @@ import withToasts from 'src/components/MessageToasts/withToasts'; import PropertiesModal from 'src/explore/components/PropertiesModal'; import ImportModelsModal from 'src/components/ImportModal/index'; import Chart, { ChartLinkedDashboard } from 'src/types/Chart'; +import Tag from 'src/types/TagType'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils'; @@ -67,6 +69,7 @@ import CertifiedBadge from 'src/components/CertifiedBadge'; import { GenericLink } from 'src/components/GenericLink/GenericLink'; import getBootstrapData from 'src/utils/getBootstrapData'; import Owner from 'src/types/Owner'; +import { loadTags } from 'src/components/Tags/utils'; import ChartCard from './ChartCard'; const FlexRowContainer = styled.div` @@ -148,7 +151,7 @@ interface ChartListProps { }; } -const Actions = styled.div` +const StyledActions = styled.div` color: ${({ theme }) => theme.colors.grayscale.base}; `; @@ -449,6 +452,27 @@ function ChartList(props: ChartListProps) { disableSortBy: true, size: 'xl', }, + { + Cell: ({ + row: { + original: { tags = [] }, + }, + }: any) => ( + // Only show custom type tags + + tag.type + ? tag.type === 1 || tag.type === 'TagTypes.custom' + : true, + )} + maxTags={3} + /> + ), + Header: t('Tags'), + accessor: 'tags', + disableSortBy: true, + hidden: !isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM), + }, { Cell: ({ row: { original } }: any) => { const handleDelete = () => @@ -465,7 +489,7 @@ function ChartList(props: ChartListProps) { } return ( - + {canDelete && ( )} - + ); }, Header: t('Actions'), @@ -567,8 +591,8 @@ function ChartList(props: ChartListProps) { [], ); - const filters: Filters = useMemo( - () => [ + const filters: Filters = useMemo(() => { + const filters_list = [ { Header: t('Owner'), key: 'owner', @@ -673,16 +697,27 @@ function ChartList(props: ChartListProps) { { label: t('No'), value: false }, ], }, - { - Header: t('Search'), - key: 'search', - id: 'slice_name', - input: 'search', - operator: FilterOperator.chartAllText, - }, - ], - [addDangerToast, favoritesFilter, props.user], - ); + ] as Filters; + if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) { + filters_list.push({ + Header: t('Tags'), + key: 'tags', + id: 'tags', + input: 'select', + operator: FilterOperator.chartTags, + unfilteredLabel: t('All'), + fetchSelects: loadTags, + }); + } + filters_list.push({ + Header: t('Search'), + key: 'search', + id: 'slice_name', + input: 'search', + operator: FilterOperator.chartAllText, + }); + return filters_list; + }, [addDangerToast, favoritesFilter, props.user]); const sortTypes = [ { diff --git a/superset-frontend/src/tags.ts b/superset-frontend/src/tags.ts new file mode 100644 index 0000000000000..ff0b8f3a339d3 --- /dev/null +++ b/superset-frontend/src/tags.ts @@ -0,0 +1,186 @@ +/** + * 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. + */ +import { JsonObject, SupersetClient } from '@superset-ui/core'; +import rison from 'rison'; +import Tag from 'src/types/TagType'; + +export const OBJECT_TYPES_VALUES = Object.freeze([ + 'dashboard', + 'chart', + 'saved_query', +]); + +export const OBJECT_TYPES = Object.freeze({ + DASHBOARD: 'dashboard', + CHART: 'chart', + QUERY: 'saved_query', +}); + +const OBJECT_TYPE_ID_MAP = { + saved_query: 1, + chart: 2, + dashboard: 3, +}; + +const map_object_type_to_id = (objectType: string) => { + if (!OBJECT_TYPES_VALUES.includes(objectType)) { + const msg = `objectType ${objectType} is invalid`; + throw new Error(msg); + } + return OBJECT_TYPE_ID_MAP[objectType]; +}; + +export function fetchAllTags( + callback: (json: JsonObject) => void, + error: (response: Response) => void, +) { + SupersetClient.get({ endpoint: `/api/v1/tag` }) + .then(({ json }) => callback(json)) + .catch(response => error(response)); +} + +export function fetchTags( + { + objectType, + objectId, + includeTypes = false, + }: { + objectType: string; + objectId: number; + includeTypes: boolean; + }, + callback: (json: JsonObject) => void, + error: (response: Response) => void, +) { + if (objectType === undefined || objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + if (!OBJECT_TYPES_VALUES.includes(objectType)) { + const msg = `objectType ${objectType} is invalid`; + throw new Error(msg); + } + SupersetClient.get({ + endpoint: `/api/v1/${objectType}/${objectId}`, + }) + .then(({ json }) => + callback( + json.result.tags.filter( + (tag: Tag) => tag.name.indexOf(':') === -1 || includeTypes, + ), + ), + ) + .catch(response => error(response)); +} +export function deleteTaggedObjects( + { objectType, objectId }: { objectType: string; objectId: number }, + tag: Tag, + callback: (text: string) => void, + error: (response: string) => void, +) { + if (objectType === undefined || objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + if (!OBJECT_TYPES_VALUES.includes(objectType)) { + const msg = `objectType ${objectType} is invalid`; + throw new Error(msg); + } + SupersetClient.delete({ + endpoint: `/api/v1/tag/${map_object_type_to_id(objectType)}/${objectId}/${ + tag.name + }`, + }) + .then(({ json }) => + json + ? callback(JSON.stringify(json)) + : callback('Successfully Deleted Tagged Objects'), + ) + .catch(response => { + const err_str = response.message; + return err_str ? error(err_str) : error('Error Deleting Tagged Objects'); + }); +} + +export function deleteTags( + tags: Tag[], + callback: (text: string) => void, + error: (response: string) => void, +) { + const tag_names = tags.map(tag => tag.name) as string[]; + SupersetClient.delete({ + endpoint: `/api/v1/tag/?q=${rison.encode(tag_names)}`, + }) + .then(({ json }) => + json.message + ? callback(json.message) + : callback('Successfully Deleted Tag'), + ) + .catch(response => { + const err_str = response.message; + return err_str ? error(err_str) : error('Error Deleting Tag'); + }); +} + +export function addTag( + { + objectType, + objectId, + includeTypes = false, + }: { + objectType: string; + objectId: number; + includeTypes: boolean; + }, + tag: string, + callback: (text: string) => void, + error: (response: Response) => void, +) { + if (objectType === undefined || objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + if (tag.indexOf(':') !== -1 && !includeTypes) { + return; + } + const objectTypeId = map_object_type_to_id(objectType); + SupersetClient.post({ + endpoint: `/api/v1/tag/${objectTypeId}/${objectId}/`, + body: JSON.stringify({ + properties: { + tags: [tag], + }, + }), + parseMethod: 'json', + headers: { 'Content-Type': 'application/json' }, + }) + .then(({ json }) => callback(JSON.stringify(json))) + .catch(response => error(response)); +} + +export function fetchObjects( + { tags = '', types }: { tags: string; types: string | null }, + callback: (json: JsonObject) => void, + error: (response: Response) => void, +) { + let url = `/api/v1/tag/get_objects/?tags=${tags}`; + if (types) { + url += `&types=${types}`; + } + SupersetClient.get({ endpoint: url }) + .then(({ json }) => callback(json.result)) + .catch(response => error(response)); +} diff --git a/superset-frontend/src/types/Chart.ts b/superset-frontend/src/types/Chart.ts index 76ec04f60b7a9..f26525cd33580 100644 --- a/superset-frontend/src/types/Chart.ts +++ b/superset-frontend/src/types/Chart.ts @@ -23,6 +23,7 @@ import { QueryFormData } from '@superset-ui/core'; import Owner from './Owner'; +import Tag from './TagType'; export type ChartLinkedDashboard = { id: number; @@ -44,6 +45,7 @@ export interface Chart { cache_timeout: number | null; thumbnail_url?: string; owners?: Owner[]; + tags?: Tag[]; last_saved_at?: string; last_saved_by?: { id: number; diff --git a/superset-frontend/src/types/TagType.ts b/superset-frontend/src/types/TagType.ts new file mode 100644 index 0000000000000..4c1b85b724915 --- /dev/null +++ b/superset-frontend/src/types/TagType.ts @@ -0,0 +1,32 @@ +/** + * 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. + */ + +import { MouseEventHandler } from 'react'; + +export interface TagType { + id?: string | number; + type?: string | number; + editable?: boolean; + onDelete?: (index: number) => void; + onClick?: MouseEventHandler; + name: string; + index?: number | undefined; +} + +export default TagType; diff --git a/superset-frontend/src/types/TaggedObject.ts b/superset-frontend/src/types/TaggedObject.ts new file mode 100644 index 0000000000000..4e36f4dbff769 --- /dev/null +++ b/superset-frontend/src/types/TaggedObject.ts @@ -0,0 +1,27 @@ +/** + * 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. + */ + +export interface TaggedObject { + id: string | number; + tag_id: number; + object_id: number; + object_type: number; +} + +export default TaggedObject; diff --git a/superset-frontend/src/views/CRUD/allentities/AllEntities.tsx b/superset-frontend/src/views/CRUD/allentities/AllEntities.tsx new file mode 100644 index 0000000000000..995c12734cf41 --- /dev/null +++ b/superset-frontend/src/views/CRUD/allentities/AllEntities.tsx @@ -0,0 +1,94 @@ +/** + * 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. + */ +import React, { useMemo } from 'react'; +import { ensureIsArray, styled, t } from '@superset-ui/core'; +import { StringParam, useQueryParam } from 'use-query-params'; +import withToasts from 'src/components/MessageToasts/withToasts'; +import AsyncSelect from 'src/components/Select/AsyncSelect'; +import { SelectValue } from 'antd/lib/select'; +import { loadTags } from 'src/components/Tags/utils'; +import { getValue } from 'src/components/Select/utils'; +import AllEntitiesTable from './AllEntitiesTable'; + +const AllEntitiesContainer = styled.div` + ${({ theme }) => ` + background-color: ${theme.colors.grayscale.light4}; + .select-control { + margin-left: ${theme.gridUnit * 4}px; + margin-right: ${theme.gridUnit * 4}px; + margin-bottom: ${theme.gridUnit * 2}px; + } + .select-control-label { + text-transform: uppercase; + font-size: ${theme.gridUnit * 3}px; + color: ${theme.colors.grayscale.base}; + margin-bottom: ${theme.gridUnit * 1}px; + }`} +`; + +const AllEntitiesNav = styled.div` + ${({ theme }) => ` + height: ${theme.gridUnit * 12.5}px; + background-color: ${theme.colors.grayscale.light5}; + margin-bottom: ${theme.gridUnit * 4}px; + .navbar-brand { + margin-left: ${theme.gridUnit * 2}px; + font-weight: ${theme.typography.weights.bold}; + }`}; +`; + +function AllEntities() { + const [tagsQuery, setTagsQuery] = useQueryParam('tags', StringParam); + + const onTagSearchChange = (value: SelectValue) => { + const tags = ensureIsArray(value).map(tag => getValue(tag)); + const tagSearch = tags.join(','); + setTagsQuery(tagSearch); + }; + + const tagsValue = useMemo( + () => + tagsQuery + ? tagsQuery.split(',').map(tag => ({ value: tag, label: tag })) + : [], + [tagsQuery], + ); + + return ( + + + {t('All Entities')} + +
+
{t('search by tags')}
+ +
+ +
+ ); +} + +export default withToasts(AllEntities); diff --git a/superset-frontend/src/views/CRUD/allentities/AllEntitiesTable.tsx b/superset-frontend/src/views/CRUD/allentities/AllEntitiesTable.tsx new file mode 100644 index 0000000000000..648644ba1570b --- /dev/null +++ b/superset-frontend/src/views/CRUD/allentities/AllEntitiesTable.tsx @@ -0,0 +1,125 @@ +/** + * 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. + */ +import React, { useState, useEffect } from 'react'; +import moment from 'moment'; +import { t, styled, logging } from '@superset-ui/core'; +import TableView, { EmptyWrapperType } from 'src/components/TableView'; +import { addDangerToast } from 'src/components/MessageToasts/actions'; +import { fetchObjects } from '../../../tags'; +import Loading from '../../../components/Loading'; + +const AllEntitiesTableContainer = styled.div` + text-align: left; + border-radius: ${({ theme }) => theme.gridUnit * 1}px 0; + margin: 0 ${({ theme }) => theme.gridUnit * 4}px; + .table { + table-layout: fixed; + } + .td { + width: 33%; + } +`; + +interface TaggedObject { + id: number; + type: string; + name: string; + url: string; + changed_on: moment.MomentInput; + created_by: number | undefined; + creator: string; +} + +interface TaggedObjects { + dashboard: TaggedObject[]; + chart: TaggedObject[]; + query: TaggedObject[]; +} + +interface AllEntitiesTableProps { + search?: string; +} + +export default function AllEntitiesTable({ + search = '', +}: AllEntitiesTableProps) { + type objectType = 'dashboard' | 'chart' | 'query'; + + const [objects, setObjects] = useState({ + dashboard: [], + chart: [], + query: [], + }); + + useEffect(() => { + fetchObjects( + { tags: search, types: null }, + (data: TaggedObject[]) => { + const objects = { dashboard: [], chart: [], query: [] }; + data.forEach(function (object) { + const object_type = object.type; + objects[object_type].push(object); + }); + setObjects(objects); + }, + (error: Response) => { + addDangerToast('Error Fetching Tagged Objects'); + logging.log(error.text); + }, + ); + }, [search]); + + const renderTable = (type: objectType) => { + const data = objects[type].map((o: TaggedObject) => ({ + [type]: {o.name}, + modified: moment.utc(o.changed_on).fromNow(), + })); + return ( + + ); + }; + + if (objects) { + return ( + +

{t('Dashboards')}

+ {renderTable('dashboard')} +
+

{t('Charts')}

+ {renderTable('chart')} +
+

{t('Queries')}

+ {renderTable('query')} +
+ ); + } + return ; +} diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index d892b24fa1b9c..d26900a29d3ff 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -28,6 +28,7 @@ import { } from 'src/views/CRUD/utils'; import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import { TagsList } from 'src/components/Tags'; import handleResourceExport from 'src/utils/export'; import Loading from 'src/components/Loading'; import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; @@ -39,6 +40,7 @@ import ListView, { } from 'src/components/ListView'; import { dangerouslyGetItemDoNotUse } from 'src/utils/localStorageHelpers'; import Owner from 'src/types/Owner'; +import Tag from 'src/types/TagType'; import withToasts from 'src/components/MessageToasts/withToasts'; import FacePile from 'src/components/FacePile'; import Icons from 'src/components/Icons'; @@ -51,6 +53,7 @@ import ImportModelsModal from 'src/components/ImportModal/index'; import Dashboard from 'src/dashboard/containers/Dashboard'; import { Dashboard as CRUDDashboard } from 'src/views/CRUD/types'; import CertifiedBadge from 'src/components/CertifiedBadge'; +import { loadTags } from 'src/components/Tags/utils'; import getBootstrapData from 'src/utils/getBootstrapData'; import DashboardCard from './DashboardCard'; import { DashboardStatus } from './types'; @@ -90,6 +93,7 @@ interface Dashboard { url: string; thumbnail_url: string; owners: Owner[]; + tags: Tag[]; created_by: object; } @@ -191,6 +195,7 @@ function DashboardList(props: DashboardListProps) { certified_by = '', certification_details = '', owners, + tags, } = json.result; return { ...dashboard, @@ -205,6 +210,7 @@ function DashboardList(props: DashboardListProps) { certified_by, certification_details, owners, + tags, }; } return dashboard; @@ -355,6 +361,31 @@ function DashboardList(props: DashboardListProps) { disableSortBy: true, size: 'xl', }, + { + Cell: ({ + row: { + original: { tags = [] }, + }, + }: { + row: { + original: { + tags: Tag[]; + }; + }; + }) => ( + // Only show custom type tags + tag.type === 'TagTypes.custom' || tag.type === 1, + )} + maxTags={3} + /> + ), + Header: t('Tags'), + accessor: 'tags', + disableSortBy: true, + hidden: !isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM), + }, { Cell: ({ row: { original } }: any) => { const handleDelete = () => @@ -469,8 +500,8 @@ function DashboardList(props: DashboardListProps) { [], ); - const filters: Filters = useMemo( - () => [ + const filters: Filters = useMemo(() => { + const filters_list = [ { Header: t('Search'), key: 'search', @@ -548,9 +579,20 @@ function DashboardList(props: DashboardListProps) { { label: t('No'), value: false }, ], }, - ], - [addDangerToast, favoritesFilter, props.user], - ); + ] as Filters; + if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) { + filters_list.push({ + Header: t('Tags'), + key: 'tags', + id: 'tags', + input: 'select', + operator: FilterOperator.chartTags, + unfilteredLabel: t('All'), + fetchSelects: loadTags, + }); + } + return filters_list; + }, [addDangerToast, favoritesFilter, props.user]); const sortTypes = [ { diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx index 291063aa88b1a..3409710db5c75 100644 --- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx +++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx @@ -43,10 +43,12 @@ import ListView, { import Loading from 'src/components/Loading'; import DeleteModal from 'src/components/DeleteModal'; import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar'; +import { TagsList } from 'src/components/Tags'; import { Tooltip } from 'src/components/Tooltip'; import { commonMenuData } from 'src/views/CRUD/data/common'; import { SavedQueryObject } from 'src/views/CRUD/types'; import copyTextToClipboard from 'src/utils/copy'; +import Tag from 'src/types/TagType'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; import ImportModelsModal from 'src/components/ImportModal/index'; import Icons from 'src/components/Icons'; @@ -361,6 +363,20 @@ function SavedQueryList({ accessor: 'changed_on_delta_humanized', size: 'xl', }, + { + Cell: ({ + row: { + original: { tags = [] }, + }, + }: any) => ( + // Only show custom type tags + tag.type === 1)} /> + ), + Header: t('Tags'), + accessor: 'tags', + disableSortBy: true, + hidden: !isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM), + }, { Cell: ({ row: { original } }: any) => { const handlePreview = () => { @@ -460,6 +476,13 @@ function SavedQueryList({ ), paginate: true, }, + { + Header: t('Tags'), + id: 'tags', + key: 'tags', + input: 'search', + operator: FilterOperator.savedQueryTags, + }, { Header: t('Search'), id: 'label', diff --git a/superset-frontend/src/views/CRUD/tags/TagCard.tsx b/superset-frontend/src/views/CRUD/tags/TagCard.tsx new file mode 100644 index 0000000000000..84e600262e642 --- /dev/null +++ b/superset-frontend/src/views/CRUD/tags/TagCard.tsx @@ -0,0 +1,123 @@ +/** + * 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. + */ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { t, useTheme } from '@superset-ui/core'; +import { CardStyles } from 'src/views/CRUD/utils'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; +import { AntdDropdown } from 'src/components'; +import { Menu } from 'src/components/Menu'; +import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import ListViewCard from 'src/components/ListViewCard'; +import Icons from 'src/components/Icons'; +import { Tag } from 'src/views/CRUD/types'; +import { deleteTags } from 'src/tags'; + +interface TagCardProps { + tag: Tag; + hasPerm: (name: string) => boolean; + bulkSelectEnabled: boolean; + refreshData: () => void; + loading: boolean; + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + tagFilter?: string; + userId?: string | number; + showThumbnails?: boolean; +} + +function TagCard({ + tag, + hasPerm, + bulkSelectEnabled, + tagFilter, + refreshData, + userId, + addDangerToast, + addSuccessToast, + showThumbnails, +}: TagCardProps) { + const canDelete = hasPerm('can_write'); + + const handleTagDelete = (tag: Tag) => { + deleteTags([tag], addSuccessToast, addDangerToast); + refreshData(); + }; + + const theme = useTheme(); + const menu = ( + + {canDelete && ( + + + {t('Are you sure you want to delete')} {tag.name}? + + } + onConfirm={() => handleTagDelete(tag)} + > + {confirmDelete => ( +
+ {t('Delete')} +
+ )} +
+
+ )} +
+ ); + return ( + + + ) : null + } + url={undefined} + linkComponent={Link} + imgFallbackURL="/static/assets/images/dashboard-card-fallback.svg" + description={t('Modified %s', tag.changed_on_delta_humanized)} + actions={ + { + e.stopPropagation(); + e.preventDefault(); + }} + > + + + + + } + /> + + ); +} + +export default TagCard; diff --git a/superset-frontend/src/views/CRUD/tags/TagList.tsx b/superset-frontend/src/views/CRUD/tags/TagList.tsx new file mode 100644 index 0000000000000..92028168f4558 --- /dev/null +++ b/superset-frontend/src/views/CRUD/tags/TagList.tsx @@ -0,0 +1,331 @@ +/** + * 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. + */ +import { t } from '@superset-ui/core'; +import React, { useMemo, useCallback } from 'react'; +import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; +import { + createFetchRelated, + createErrorHandler, + Actions, +} from 'src/views/CRUD/utils'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; +import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; +import ListView, { + ListViewProps, + Filters, + FilterOperator, +} from 'src/components/ListView'; +import { dangerouslyGetItemDoNotUse } from 'src/utils/localStorageHelpers'; +import withToasts from 'src/components/MessageToasts/withToasts'; +import Icons from 'src/components/Icons'; +import { Tooltip } from 'src/components/Tooltip'; +import FacePile from 'src/components/FacePile'; +import { Link } from 'react-router-dom'; +import { deleteTags } from 'src/tags'; +import { Tag as AntdTag } from 'antd'; +import { Tag } from '../types'; +import TagCard from './TagCard'; + +const PAGE_SIZE = 25; + +interface TagListProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + user: { + userId: string | number; + firstName: string; + lastName: string; + }; +} + +function TagList(props: TagListProps) { + const { + addDangerToast, + addSuccessToast, + user: { userId }, + } = props; + + const { + state: { + loading, + resourceCount: tagCount, + resourceCollection: tags, + bulkSelectEnabled, + }, + hasPerm, + fetchData, + toggleBulkSelect, + refreshData, + } = useListViewResource('tag', t('tag'), addDangerToast); + + // TODO: Fix usage of localStorage keying on the user id + const userKey = dangerouslyGetItemDoNotUse(userId?.toString(), null); + + const canDelete = hasPerm('can_write'); + + const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; + + function handleTagsDelete( + tags: Tag[], + callback: (text: string) => void, + error: (text: string) => void, + ) { + // TODO what permissions need to be checked here? + deleteTags(tags, callback, error); + refreshData(); + } + + const columns = useMemo( + () => [ + { + Cell: ({ + row: { + original: { name: tagName }, + }, + }: any) => ( + + + {tagName} + + + ), + Header: t('Name'), + accessor: 'name', + }, + { + Cell: ({ + row: { + original: { changed_on_delta_humanized: changedOn }, + }, + }: any) => {changedOn}, + Header: t('Modified'), + accessor: 'changed_on_delta_humanized', + size: 'xl', + }, + { + Cell: ({ + row: { + original: { created_by: createdBy }, + }, + }: any) => (createdBy ? : ''), + Header: t('Created by'), + accessor: 'created_by', + disableSortBy: true, + size: 'xl', + }, + { + Cell: ({ row: { original } }: any) => { + const handleDelete = () => + handleTagsDelete([original], addSuccessToast, addDangerToast); + return ( + + {canDelete && ( + + {t('Are you sure you want to delete')}{' '} + {original.dashboard_title}? + + } + onConfirm={handleDelete} + > + {confirmDelete => ( + + + + + + )} + + )} + + ); + }, + Header: t('Actions'), + id: 'actions', + hidden: !canDelete, + disableSortBy: true, + }, + ], + [userId, canDelete, refreshData, addSuccessToast, addDangerToast], + ); + + const filters: Filters = useMemo(() => { + const filters_list = [ + { + Header: t('Created by'), + id: 'created_by', + input: 'select', + operator: FilterOperator.relationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'tag', + 'created_by', + createErrorHandler(errMsg => + addDangerToast( + t( + 'An error occurred while fetching tag created by values: %s', + errMsg, + ), + ), + ), + props.user, + ), + paginate: true, + }, + { + Header: t('Search'), + id: 'name', + input: 'search', + operator: FilterOperator.contains, + }, + ] as Filters; + return filters_list; + }, [addDangerToast, props.user]); + + const sortTypes = [ + { + desc: false, + id: 'name', + label: t('Alphabetical'), + value: 'alphabetical', + }, + { + desc: true, + id: 'changed_on_delta_humanized', + label: t('Recently modified'), + value: 'recently_modified', + }, + { + desc: false, + id: 'changed_on_delta_humanized', + label: t('Least recently modified'), + value: 'least_recently_modified', + }, + ]; + + const renderCard = useCallback( + (tag: Tag) => ( + + ), + [ + addDangerToast, + addSuccessToast, + bulkSelectEnabled, + hasPerm, + loading, + userId, + refreshData, + userKey, + ], + ); + + const subMenuButtons: SubMenuProps['buttons'] = []; + if (canDelete) { + subMenuButtons.push({ + name: t('Bulk select'), + buttonStyle: 'secondary', + 'data-test': 'bulk-select', + onClick: toggleBulkSelect, + }); + } + + const handleBulkDelete = (tagsToDelete: Tag[]) => + handleTagsDelete(tagsToDelete, addSuccessToast, addDangerToast); + + return ( + <> + + + {confirmDelete => { + const bulkActions: ListViewProps['bulkActions'] = []; + if (canDelete) { + bulkActions.push({ + key: 'delete', + name: t('Delete'), + type: 'danger', + onSelect: confirmDelete, + }); + } + return ( + <> + + bulkActions={bulkActions} + bulkSelectEnabled={bulkSelectEnabled} + cardSortSelectOptions={sortTypes} + className="dashboard-list-view" + columns={columns} + count={tagCount} + data={tags.filter(tag => !tag.name.includes(':'))} + disableBulkSelect={toggleBulkSelect} + fetchData={fetchData} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + showThumbnails={ + userKey + ? userKey.thumbnails + : isFeatureEnabled(FeatureFlag.THUMBNAILS) + } + renderCard={renderCard} + defaultViewMode={ + isFeatureEnabled(FeatureFlag.LISTVIEWS_DEFAULT_CARD_VIEW) + ? 'card' + : 'table' + } + /> + + ); + }} + + + ); +} + +export default withToasts(TagList); diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index 7dd5d74f13391..87748d1539711 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -137,5 +137,12 @@ export type ImportResourceName = | 'dataset' | 'saved_query'; +export interface Tag { + changed_on_delta_humanized: string; + name: string; + id: number; + created_by: object; +} + export type DatabaseObject = Partial & Pick; diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index 4b839739d47cc..190f93dc8b439 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -66,6 +66,10 @@ import { WelcomeTable } from './welcome/types'; risonRef.next_id = new RegExp(idrx, 'g'); })(); +export const Actions = styled.div` + color: ${({ theme }) => theme.colors.grayscale.base}; +`; + const createFetchResourceMethod = (method: string) => ( diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index cbab3f09fdf39..633966ca74184 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import React, { lazy } from 'react'; // not lazy loaded since this is the home page. @@ -105,6 +106,15 @@ const SavedQueryList = lazy( /* webpackChunkName: "SavedQueryList" */ 'src/views/CRUD/data/savedquery/SavedQueryList' ), ); +const AllEntitiesPage = lazy( + () => + import( + /* webpackChunkName: "AllEntities" */ 'src/views/CRUD/allentities/AllEntities' + ), +); +const TagsPage = lazy( + () => import(/* webpackChunkName: "TagList" */ 'src/views/CRUD/tags/TagList'), +); type Routes = { path: string; @@ -202,6 +212,17 @@ export const routes: Routes = [ }, ]; +if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) { + routes.push({ + path: '/superset/all_entities/', + Component: AllEntitiesPage, + }); + routes.push({ + path: '/superset/tags/', + Component: TagsPage, + }); +} + const frontEndRoutes = routes .map(r => r.path) .reduce( diff --git a/superset/charts/api.py b/superset/charts/api.py index e9ba4feddb8b8..8cadb34a3f67c 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -54,6 +54,7 @@ ChartFavoriteFilter, ChartFilter, ChartHasCreatedByFilter, + ChartTagFilter, ) from superset.charts.schemas import ( CHART_SCHEMAS, @@ -140,6 +141,9 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "query_context", "is_managed_externally", ] + if is_feature_enabled("TAGGING_SYSTEM"): + show_columns += ["tags.id", "tags.name", "tags.type"] + show_select_columns = show_columns + ["table.id"] list_columns = [ "is_managed_externally", @@ -182,6 +186,8 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "url", "viz_type", ] + if is_feature_enabled("TAGGING_SYSTEM"): + list_columns += ["tags.id", "tags.name", "tags.type"] list_select_columns = list_columns + ["changed_by_fk", "changed_on"] order_columns = [ "changed_by.first_name", @@ -210,6 +216,8 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "slice_name", "viz_type", ] + if is_feature_enabled("TAGGING_SYSTEM"): + search_columns += ["tags"] base_order = ("changed_on", "desc") base_filters = [["id", ChartFilter, lambda: []]] search_filters = { @@ -217,6 +225,8 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "slice_name": [ChartAllTextFilter], "created_by": [ChartHasCreatedByFilter, ChartCreatedByMeFilter], } + if is_feature_enabled("TAGGING_SYSTEM"): + search_filters["tags"] = [ChartTagFilter] # Will just affect _info endpoint edit_columns = ["slice_name"] diff --git a/superset/charts/filters.py b/superset/charts/filters.py index fd3fff7f6e2fa..ddd1f54b592ce 100644 --- a/superset/charts/filters.py +++ b/superset/charts/filters.py @@ -26,7 +26,7 @@ from superset.models.slice import Slice from superset.utils.core import get_user_id from superset.views.base import BaseFilter -from superset.views.base_api import BaseFavoriteFilter +from superset.views.base_api import BaseFavoriteFilter, BaseTagFilter class ChartAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods @@ -57,6 +57,16 @@ class ChartFavoriteFilter(BaseFavoriteFilter): # pylint: disable=too-few-public model = Slice +class ChartTagFilter(BaseTagFilter): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all dashboards that a user has favored + """ + + arg_name = "chart_tags" + class_name = "slice" + model = Slice + + class ChartCertifiedFilter(BaseFilter): # pylint: disable=too-few-public-methods """ Custom filter for the GET list that filters all certified charts diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 5e76c8ee389c1..3ee1b6f1f4ec6 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -149,6 +149,12 @@ } +class TagSchema(Schema): + id = fields.Int() + name = fields.String() + type = fields.String() + + class ChartEntityResponseSchema(Schema): """ Schema for a chart object diff --git a/superset/constants.py b/superset/constants.py index 5c1f0e36fe264..07bbe6c07cea5 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -143,6 +143,10 @@ class RouteMethod: # pylint: disable=too-few-public-methods "delete_ssh_tunnel": "write", "get_updated_since": "read", "stop_query": "read", + "get_objects": "read", + "get_all_objects": "read", + "add_objects": "write", + "delete_object": "write", } EXTRA_FORM_DATA_APPEND_KEYS = { diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 64ea637c663d8..f930da4290237 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -61,6 +61,7 @@ DashboardCreatedByMeFilter, DashboardFavoriteFilter, DashboardHasCreatedByFilter, + DashboardTagFilter, DashboardTitleOrSlugFilter, FilterRelatedRoles, ) @@ -187,6 +188,8 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "roles.name", "is_managed_externally", ] + if is_feature_enabled("TAGGING_SYSTEM"): + list_columns += ["tags.id", "tags.name", "tags.type"] list_select_columns = list_columns + ["changed_on", "created_on", "changed_by_fk"] order_columns = [ "changed_by.first_name", @@ -212,20 +215,37 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: edit_columns = add_columns search_columns = ( - "created_by", - "changed_by", - "dashboard_title", - "id", - "owners", - "published", - "roles", - "slug", + ( + "created_by", + "changed_by", + "dashboard_title", + "id", + "owners", + "published", + "roles", + "slug", + "tags", + ) + if is_feature_enabled("TAGGING_SYSTEM") + else ( + "created_by", + "changed_by", + "dashboard_title", + "id", + "owners", + "published", + "roles", + "slug", + ) ) search_filters = { "dashboard_title": [DashboardTitleOrSlugFilter], "id": [DashboardFavoriteFilter, DashboardCertifiedFilter], "created_by": [DashboardCreatedByMeFilter, DashboardHasCreatedByFilter], } + if is_feature_enabled("TAGGING_SYSTEM"): + search_filters["tags"] = [DashboardTagFilter] + base_order = ("changed_on", "desc") add_model_schema = DashboardPostSchema() diff --git a/superset/dashboards/filters.py b/superset/dashboards/filters.py index e09609ff511e0..d458671311386 100644 --- a/superset/dashboards/filters.py +++ b/superset/dashboards/filters.py @@ -31,7 +31,7 @@ from superset.security.guest_token import GuestTokenResourceType, GuestUser from superset.utils.core import get_user_id from superset.views.base import BaseFilter -from superset.views.base_api import BaseFavoriteFilter +from superset.views.base_api import BaseFavoriteFilter, BaseTagFilter class DashboardTitleOrSlugFilter(BaseFilter): # pylint: disable=too-few-public-methods @@ -77,6 +77,16 @@ class DashboardFavoriteFilter( # pylint: disable=too-few-public-methods model = Dashboard +class DashboardTagFilter(BaseTagFilter): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all dashboards that a user has favored + """ + + arg_name = "dashboard_tags" + class_name = "Dashboard" + model = Dashboard + + def is_uuid(value: Union[str, int]) -> bool: try: uuid.UUID(str(value)) diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index f0d05445aae77..f23eee9d27126 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -149,6 +149,12 @@ class RolesSchema(Schema): name = fields.String() +class TagSchema(Schema): + id = fields.Int() + name = fields.String() + type = fields.String() + + class DashboardGetResponseSchema(Schema): id = fields.Int() slug = fields.String() @@ -168,6 +174,7 @@ class DashboardGetResponseSchema(Schema): charts = fields.List(fields.String(description=charts_description)) owners = fields.List(fields.Nested(UserSchema)) roles = fields.List(fields.Nested(RolesSchema)) + tags = fields.Nested(TagSchema, many=True) changed_on_humanized = fields.String(data_key="changed_on_delta_humanized") is_managed_externally = fields.Boolean(allow_none=True, default=False) diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 90a888c8749f5..cda0651456b9f 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -151,8 +151,10 @@ def init_views(self) -> None: from superset.reports.logs.api import ReportExecutionLogRestApi from superset.security.api import SecurityRestApi from superset.sqllab.api import SqlLabRestApi + from superset.tags.api import TagRestApi from superset.views.access_requests import AccessRequestsModelView from superset.views.alerts import AlertView, ReportView + from superset.views.all_entities import TaggedObjectsModelView, TaggedObjectView from superset.views.annotations import AnnotationLayerView from superset.views.api import Api from superset.views.chart.views import SliceAsync, SliceModelView @@ -186,7 +188,7 @@ def init_views(self) -> None: TableSchemaView, TabStateView, ) - from superset.views.tags import TagView + from superset.views.tags import TagModelView, TagView from superset.views.users.api import CurrentUserRestApi # @@ -220,6 +222,7 @@ def init_views(self) -> None: appbuilder.add_api(ReportScheduleRestApi) appbuilder.add_api(ReportExecutionLogRestApi) appbuilder.add_api(SavedQueryRestApi) + appbuilder.add_api(TagRestApi) appbuilder.add_api(SqlLabRestApi) # # Setup regular views @@ -321,6 +324,7 @@ def init_views(self) -> None: appbuilder.add_view_no_menu(TableModelView) appbuilder.add_view_no_menu(TableSchemaView) appbuilder.add_view_no_menu(TabStateView) + appbuilder.add_view_no_menu(TaggedObjectView) appbuilder.add_view_no_menu(TagView) appbuilder.add_view_no_menu(ReportView) @@ -363,9 +367,24 @@ def init_views(self) -> None: icon="fa-search", category_icon="fa-flask", category="SQL Lab", - category_label=__("SQL"), + category_label=__("SQL Lab"), + ) + appbuilder.add_view( + TaggedObjectsModelView, + "All Entities", + label=__("All Entities"), + icon="", + category_icon="", + menu_cond=lambda: feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"), + ) + appbuilder.add_view( + TagModelView, + "Tags", + label=__("Tags"), + icon="", + category_icon="", + menu_cond=lambda: feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"), ) - appbuilder.add_api(LogRestApi) appbuilder.add_view( LogModelView, diff --git a/superset/models/dashboard.py b/superset/models/dashboard.py index 0e0bf56f585d6..289fbd093e201 100644 --- a/superset/models/dashboard.py +++ b/superset/models/dashboard.py @@ -148,6 +148,14 @@ class Dashboard(Model, AuditMixinNullable, ImportExportMixin): Slice, secondary=dashboard_slices, backref="dashboards" ) owners = relationship(security_manager.user_model, secondary=dashboard_user) + if is_feature_enabled("TAGGING_SYSTEM"): + tags = relationship( + "Tag", + secondary="tagged_object", + primaryjoin="and_(Dashboard.id == TaggedObject.object_id)", + secondaryjoin="and_(TaggedObject.tag_id == Tag.id, " + "TaggedObject.object_type == 'dashboard')", + ) published = Column(Boolean, default=False) is_managed_externally = Column(Boolean, nullable=False, default=False) external_url = Column(Text, nullable=True) diff --git a/superset/models/slice.py b/superset/models/slice.py index 332d51d1af939..c79679e4c8208 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -97,6 +97,14 @@ class Slice( # pylint: disable=too-many-public-methods security_manager.user_model, foreign_keys=[last_saved_by_fk] ) owners = relationship(security_manager.user_model, secondary=slice_user) + if is_feature_enabled("TAGGING_SYSTEM"): + tags = relationship( + "Tag", + secondary="tagged_object", + primaryjoin="and_(Slice.id == TaggedObject.object_id)", + secondaryjoin="and_(TaggedObject.tag_id == Tag.id, " + "TaggedObject.object_type == 'chart')", + ) table = relationship( "SqlaTable", foreign_keys=[datasource_id], diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index 23c13629b95ed..f91a22a34a3c8 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -42,7 +42,7 @@ from sqlalchemy.engine.url import URL from sqlalchemy.orm import backref, relationship -from superset import security_manager +from superset import is_feature_enabled, security_manager from superset.jinja_context import BaseTemplateProcessor, get_template_processor from superset.models.helpers import ( AuditMixinNullable, @@ -366,6 +366,14 @@ class SavedQuery(Model, AuditMixinNullable, ExtraJSONMixin, ImportExportMixin): ) rows = Column(Integer, nullable=True) last_run = Column(DateTime, nullable=True) + if is_feature_enabled("TAGGING_SYSTEM"): + tags = relationship( + "Tag", + secondary="tagged_object", + primaryjoin="and_(SavedQuery.id == TaggedObject.object_id)", + secondaryjoin="and_(TaggedObject.tag_id == Tag.id, " + "TaggedObject.object_type == 'saved_query')", + ) export_parent = "database" export_fields = [ diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py index 2b70b582bb5f4..69aabf373707b 100644 --- a/superset/queries/saved_queries/api.py +++ b/superset/queries/saved_queries/api.py @@ -26,6 +26,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import ngettext +from superset import is_feature_enabled from superset.commands.importers.exceptions import ( IncorrectFormatError, NoValidFilesFoundError, @@ -50,6 +51,7 @@ SavedQueryAllTextFilter, SavedQueryFavoriteFilter, SavedQueryFilter, + SavedQueryTagFilter, ) from superset.queries.saved_queries.schemas import ( get_delete_ids_schema, @@ -108,15 +110,18 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): "database.id", "db_id", "description", + "extra", "id", "label", + "last_run_delta_humanized", + "rows", "schema", "sql", "sql_tables", - "rows", - "last_run_delta_humanized", - "extra", ] + if is_feature_enabled("TAGGING_SYSTEM"): + list_columns += ["tags.id", "tags.name", "tags.type"] + list_select_columns = list_columns + ["changed_by_fk", "changed_on"] add_columns = [ "db_id", "description", @@ -140,10 +145,14 @@ class SavedQueryRestApi(BaseSupersetModelRestApi): ] search_columns = ["id", "database", "label", "schema", "created_by"] + if is_feature_enabled("TAGGING_SYSTEM"): + search_columns += ["tags"] search_filters = { "id": [SavedQueryFavoriteFilter], "label": [SavedQueryAllTextFilter], } + if is_feature_enabled("TAGGING_SYSTEM"): + search_filters["tags"] = [SavedQueryTagFilter] apispec_parameter_schemas = { "get_delete_ids_schema": get_delete_ids_schema, diff --git a/superset/queries/saved_queries/filters.py b/superset/queries/saved_queries/filters.py index c53ff5619d1d1..a9e7006b63f4f 100644 --- a/superset/queries/saved_queries/filters.py +++ b/superset/queries/saved_queries/filters.py @@ -24,7 +24,7 @@ from superset.models.sql_lab import SavedQuery from superset.views.base import BaseFilter -from superset.views.base_api import BaseFavoriteFilter +from superset.views.base_api import BaseFavoriteFilter, BaseTagFilter class SavedQueryAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods @@ -58,6 +58,16 @@ class SavedQueryFavoriteFilter( model = SavedQuery +class SavedQueryTagFilter(BaseTagFilter): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all dashboards that a user has favored + """ + + arg_name = "saved_query_tags" + class_name = "query" + model = SavedQuery + + class SavedQueryFilter(BaseFilter): # pylint: disable=too-few-public-methods def apply(self, query: BaseQuery, value: Any) -> BaseQuery: """ diff --git a/superset/tags/api.py b/superset/tags/api.py new file mode 100644 index 0000000000000..d7c57c0320705 --- /dev/null +++ b/superset/tags/api.py @@ -0,0 +1,386 @@ +# 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. +import logging +from typing import Any + +from flask import request, Response +from flask_appbuilder.api import expose, protect, rison, safe +from flask_appbuilder.models.sqla.interface import SQLAInterface + +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod +from superset.extensions import event_logger +from superset.tags.commands.create import CreateCustomTagCommand +from superset.tags.commands.delete import DeleteTaggedObjectCommand, DeleteTagsCommand +from superset.tags.commands.exceptions import ( + TagCreateFailedError, + TagDeleteFailedError, + TaggedObjectDeleteFailedError, + TaggedObjectNotFoundError, + TagInvalidError, + TagNotFoundError, +) +from superset.tags.dao import TagDAO +from superset.tags.models import ObjectTypes, Tag +from superset.tags.schemas import ( + delete_tags_schema, + openapi_spec_methods_override, + TaggedObjectEntityResponseSchema, + TagGetResponseSchema, + TagPostSchema, +) +from superset.views.base_api import ( + BaseSupersetModelRestApi, + RelatedFieldFilter, + statsd_metrics, +) +from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners + +logger = logging.getLogger(__name__) + + +class TagRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(Tag) + include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { + RouteMethod.RELATED, + "bulk_delete", + "get_objects", + "get_all_objects", + "add_objects", + "delete_object", + } + + resource_name = "tag" + allow_browser_login = True + + class_permission_name = "Tag" + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP + + list_columns = [ + "id", + "name", + "type", + "changed_by.first_name", + "changed_by.last_name", + "changed_on_delta_humanized", + "created_by.first_name", + "created_by.last_name", + ] + + list_select_columns = list_columns + + show_columns = [ + "id", + "name", + "type", + "changed_by.first_name", + "changed_by.last_name", + "changed_on_delta_humanized", + "created_by.first_name", + "created_by.last_name", + "created_by", + ] + + base_related_field_filters = { + "created_by": [["id", BaseFilterRelatedUsers, lambda: []]], + } + + related_field_filters = { + "created_by": RelatedFieldFilter("first_name", FilterRelatedOwners), + } + allowed_rel_fields = {"created_by"} + + add_model_schema = TagPostSchema() + tag_get_response_schema = TagGetResponseSchema() + object_entity_response_schema = TaggedObjectEntityResponseSchema() + + openapi_spec_tag = "Tags" + """ Override the name set for this collection of endpoints """ + openapi_spec_component_schemas = ( + TagGetResponseSchema, + TaggedObjectEntityResponseSchema, + ) + apispec_parameter_schemas = { + "delete_tags_schema": delete_tags_schema, + } + openapi_spec_methods = openapi_spec_methods_override + """ Overrides GET methods OpenApi descriptions """ + + def __repr__(self) -> str: + """Deterministic string representation of the API instance for etag_cache.""" + return ( + "Superset.tags.api.TagRestApi@v" + f'{self.appbuilder.app.config["VERSION_STRING"]}' + f'{self.appbuilder.app.config["VERSION_SHA"]}' + ) + + @expose("///", methods=["POST"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.add_objects", + log_to_statsd=False, + ) + def add_objects(self, object_type: ObjectTypes, object_id: int) -> Response: + """Adds tags to an object. Creates new tags if they do not already exist + --- + post: + description: >- + Add tags to an object.. + requestBody: + description: Tag schema + required: true + content: + application/json: + schema: + type: object + properties: + tags: + description: list of tag names to add to object + type: array + items: + type: string + parameters: + - in: path + schema: + type: integer + name: object_type + - in: path + schema: + type: integer + name: object_id + responses: + 201: + description: Tag added + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + tags = request.json["properties"]["tags"] + # This validates custom Schema with custom validations + CreateCustomTagCommand(object_type, object_id, tags).run() + return self.response(201) + except KeyError: + return self.response( + 400, + message="Missing required field 'tags' in 'properties'", + ) + except TagInvalidError: + return self.response(422, message="Invalid tag") + except TagCreateFailedError as ex: + logger.error( + "Error creating model %s: %s", + self.__class__.__name__, + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) + + @expose("////", methods=["DELETE"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete_object", + log_to_statsd=True, + ) + def delete_object( + self, object_type: ObjectTypes, object_id: int, tag: str + ) -> Response: + """Deletes a Tagged Object + --- + delete: + description: >- + Deletes a Tagged Object. + parameters: + - in: path + schema: + type: string + name: tag + - in: path + schema: + type: integer + name: object_type + - in: path + schema: + type: integer + name: object_id + responses: + 200: + description: Chart delete + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + try: + DeleteTaggedObjectCommand(object_type, object_id, tag).run() + return self.response(200, message="OK") + except TagInvalidError: + return self.response_422() + except TagNotFoundError: + return self.response_404() + except TaggedObjectNotFoundError: + return self.response_404() + except TaggedObjectDeleteFailedError as ex: + logger.error( + "Error deleting tagged object %s: %s", + self.__class__.__name__, + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) + + @expose("/", methods=["DELETE"]) + @protect() + @safe + @statsd_metrics + @rison(delete_tags_schema) + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete", + log_to_statsd=False, + ) + def bulk_delete(self, **kwargs: Any) -> Response: + """Delete Tags + --- + delete: + description: >- + Deletes multiple Tags. This will remove all tagged objects with this tag + parameters: + - in: query + name: q + content: + application/json: + schema: + $ref: '#/components/schemas/delete_tags_schema' + + responses: + 200: + description: Deletes multiple Tags + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + tags = kwargs["rison"] + try: + DeleteTagsCommand(tags).run() + return self.response(200, message=f"Deleted {len(tags)} tags") + except TagNotFoundError: + return self.response_404() + except TagInvalidError as ex: + return self.response(422, message=f"Invalid tag parameters: {tags}. {ex}") + except TagDeleteFailedError as ex: + return self.response_422(message=str(ex)) + + @expose("/get_objects/", methods=["GET"]) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_objects", + log_to_statsd=False, + ) + def get_objects(self) -> Response: + """Gets all objects associated with a Tag + --- + get: + description: >- + Gets all objects associated with a Tag. + parameters: + - in: path + schema: + type: integer + name: tag_id + responses: + 200: + description: List of tagged objects associated with a Tag + content: + application/json: + schema: + type: object + properties: + result: + type: array + items: + $ref: '#/components/schemas/TaggedObjectEntityResponseSchema' + 302: + description: Redirects to the current digest + 400: + $ref: '#/components/responses/400' + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + tags = [tag for tag in request.args.get("tags", "").split(",") if tag] + # filter types + types = [type_ for type_ in request.args.get("types", "").split(",") if type_] + + try: + tagged_objects = TagDAO.get_tagged_objects_for_tags(tags, types) + result = [ + self.object_entity_response_schema.dump(tagged_object) + for tagged_object in tagged_objects + ] + return self.response(200, result=result) + except TagInvalidError as ex: + return self.response_422(message=ex.normalized_messages()) + except TagCreateFailedError as ex: + logger.error( + "Error creating model %s: %s", + self.__class__.__name__, + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) diff --git a/superset/tags/commands/__init__.py b/superset/tags/commands/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/superset/tags/commands/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/superset/tags/commands/create.py b/superset/tags/commands/create.py new file mode 100644 index 0000000000000..e9afe4a38d4a9 --- /dev/null +++ b/superset/tags/commands/create.py @@ -0,0 +1,65 @@ +# 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. +import logging +from typing import List + +from superset.commands.base import BaseCommand, CreateMixin +from superset.dao.exceptions import DAOCreateFailedError +from superset.tags.commands.exceptions import TagCreateFailedError, TagInvalidError +from superset.tags.commands.utils import to_object_type +from superset.tags.dao import TagDAO +from superset.tags.models import ObjectTypes + +logger = logging.getLogger(__name__) + + +class CreateCustomTagCommand(CreateMixin, BaseCommand): + def __init__(self, object_type: ObjectTypes, object_id: int, tags: List[str]): + self._object_type = object_type + self._object_id = object_id + self._tags = tags + + def run(self) -> None: + self.validate() + try: + object_type = to_object_type(self._object_type) + if object_type is None: + raise TagCreateFailedError(f"invalid object type {self._object_type}") + TagDAO.create_custom_tagged_objects( + object_type=object_type, + object_id=self._object_id, + tag_names=self._tags, + ) + except DAOCreateFailedError as ex: + logger.exception(ex.exception) + raise TagCreateFailedError() from ex + + def validate(self) -> None: + exceptions = [] + # Validate object_id + if self._object_id == 0: + exceptions.append(TagCreateFailedError()) + # Validate object type + object_type = to_object_type(self._object_type) + if not object_type: + exceptions.append( + TagCreateFailedError(f"invalid object type {self._object_type}") + ) + if exceptions: + exception = TagInvalidError() + exception.add_list(exceptions) + raise exception diff --git a/superset/tags/commands/delete.py b/superset/tags/commands/delete.py new file mode 100644 index 0000000000000..63a514e5996d7 --- /dev/null +++ b/superset/tags/commands/delete.py @@ -0,0 +1,115 @@ +# 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. +import logging +from typing import List + +from superset.commands.base import BaseCommand +from superset.dao.exceptions import DAODeleteFailedError +from superset.tags.commands.exceptions import ( + TagDeleteFailedError, + TaggedObjectDeleteFailedError, + TaggedObjectNotFoundError, + TagInvalidError, + TagNotFoundError, +) +from superset.tags.commands.utils import to_object_type +from superset.tags.dao import TagDAO +from superset.tags.models import ObjectTypes +from superset.views.base import DeleteMixin + +logger = logging.getLogger(__name__) + + +class DeleteTaggedObjectCommand(DeleteMixin, BaseCommand): + def __init__(self, object_type: ObjectTypes, object_id: int, tag: str): + self._object_type = object_type + self._object_id = object_id + self._tag = tag + + def run(self) -> None: + self.validate() + try: + object_type = to_object_type(self._object_type) + if object_type is None: + raise TaggedObjectDeleteFailedError( + f"invalid object type {self._object_type}" + ) + TagDAO.delete_tagged_object(object_type, self._object_id, self._tag) + except DAODeleteFailedError as ex: + logger.exception(ex.exception) + raise TaggedObjectDeleteFailedError() from ex + + def validate(self) -> None: + exceptions = [] + # Validate required arguments provided + if not (self._object_id and self._object_type): + exceptions.append(TaggedObjectDeleteFailedError()) + # Validate tagged object exists + tag = TagDAO.find_by_name(self._tag) + if not tag: + exceptions.append( + TaggedObjectDeleteFailedError(f"could not find tag: {self._tag}") + ) + else: + # Validate object type + object_type = to_object_type(self._object_type) + if object_type is None: + exceptions.append( + TaggedObjectDeleteFailedError( + f"invalid object type {self._object_type}" + ) + ) + else: + tagged_object = TagDAO.find_tagged_object( + object_type=object_type, object_id=self._object_id, tag_id=tag.id + ) + if tagged_object is None: + exceptions.append( + TaggedObjectNotFoundError( + object_id=self._object_id, + object_type=object_type.name, + tag_name=self._tag, + ) + ) + if exceptions: + exception = TagInvalidError() + exception.add_list(exceptions) + raise exception + + +class DeleteTagsCommand(DeleteMixin, BaseCommand): + def __init__(self, tags: List[str]): + self._tags = tags + + def run(self) -> None: + self.validate() + try: + TagDAO.delete_tags(self._tags) + except DAODeleteFailedError as ex: + logger.exception(ex.exception) + raise TagDeleteFailedError() from ex + + def validate(self) -> None: + exceptions = [] + # Validate tag exists + for tag in self._tags: + if not TagDAO.find_by_name(tag): + exceptions.append(TagNotFoundError(tag)) + if exceptions: + exception = TagInvalidError() + exception.add_list(exceptions) + raise exception diff --git a/superset/tags/commands/exceptions.py b/superset/tags/commands/exceptions.py new file mode 100644 index 0000000000000..9847c949bf7ec --- /dev/null +++ b/superset/tags/commands/exceptions.py @@ -0,0 +1,65 @@ +# 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 Optional + +from flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import ( + CommandException, + CommandInvalidError, + CreateFailedError, + DeleteFailedError, +) + + +class TagInvalidError(CommandInvalidError): + message = _("Tag parameters are invalid.") + + +class TagCreateFailedError(CreateFailedError): + message = _("Tag could not be created.") + + +class TagDeleteFailedError(DeleteFailedError): + message = _("Tag could not be deleted.") + + +class TaggedObjectDeleteFailedError(DeleteFailedError): + message = _("Tagged Object could not be deleted.") + + +class TagNotFoundError(CommandException): + def __init__(self, tag_name: Optional[str] = None) -> None: + message = "Tag not found." + if tag_name: + message = f"Tag with name {tag_name} not found." + super().__init__(message) + + +class TaggedObjectNotFoundError(CommandException): + def __init__( + self, + object_id: Optional[int] = None, + object_type: Optional[str] = None, + tag_name: Optional[str] = None, + ) -> None: + message = "Tagged Object not found." + if object_id and object_type and tag_name: + message = f'Tagged object with object_id: {object_id} \ + object_type: {object_type} \ + and tag name: "{tag_name}" could not be found' + super().__init__(message) diff --git a/superset/tags/commands/utils.py b/superset/tags/commands/utils.py new file mode 100644 index 0000000000000..2993365b7ac75 --- /dev/null +++ b/superset/tags/commands/utils.py @@ -0,0 +1,29 @@ +# 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 Optional, Union + +from superset.tags.models import ObjectTypes + + +def to_object_type(object_type: Union[ObjectTypes, int, str]) -> Optional[ObjectTypes]: + if isinstance(object_type, ObjectTypes): + return object_type + for type_ in ObjectTypes: + if object_type in [type_.value, type_.name]: + return type_ + return None diff --git a/superset/tags/dao.py b/superset/tags/dao.py new file mode 100644 index 0000000000000..c676b4ab3c25c --- /dev/null +++ b/superset/tags/dao.py @@ -0,0 +1,260 @@ +# 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. +import logging +from operator import and_ +from typing import Any, Dict, List, Optional + +from sqlalchemy.exc import SQLAlchemyError + +from superset.dao.base import BaseDAO +from superset.dao.exceptions import DAOCreateFailedError, DAODeleteFailedError +from superset.extensions import db +from superset.models.dashboard import Dashboard +from superset.models.slice import Slice +from superset.models.sql_lab import SavedQuery +from superset.tags.models import get_tag, ObjectTypes, Tag, TaggedObject, TagTypes + +logger = logging.getLogger(__name__) + + +class TagDAO(BaseDAO): + model_cls = Tag + # base_filter = TagAccessFilter + + @staticmethod + def validate_tag_name(tag_name: str) -> bool: + invalid_characters = [":", ","] + for invalid_character in invalid_characters: + if invalid_character in tag_name: + return False + return True + + @staticmethod + def create_custom_tagged_objects( + object_type: ObjectTypes, object_id: int, tag_names: List[str] + ) -> None: + tagged_objects = [] + for name in tag_names: + if not TagDAO.validate_tag_name(name): + raise DAOCreateFailedError( + message="Invalid Tag Name (cannot contain ':' or ',')" + ) + type_ = TagTypes.custom + tag_name = name.strip() + tag = TagDAO.get_by_name(tag_name, type_) + tagged_objects.append( + TaggedObject(object_id=object_id, object_type=object_type, tag=tag) + ) + + db.session.add_all(tagged_objects) + db.session.commit() + + @staticmethod + def delete_tagged_object( + object_type: ObjectTypes, object_id: int, tag_name: str + ) -> None: + """ + deletes a tagged object by the object_id, object_type, and tag_name + """ + tag = TagDAO.find_by_name(tag_name.strip()) + if not tag: + raise DAODeleteFailedError( + message=f"Tag with name {tag_name} does not exist." + ) + + tagged_object = db.session.query(TaggedObject).filter( + TaggedObject.tag_id == tag.id, + TaggedObject.object_type == object_type, + TaggedObject.object_id == object_id, + ) + if not tagged_object: + raise DAODeleteFailedError( + message=f'Tagged object with object_id: {object_id} \ + object_type: {object_type} \ + and tag name: "{tag_name}" could not be found' + ) + try: + db.session.delete(tagged_object.one()) + db.session.commit() + except SQLAlchemyError as ex: # pragma: no cover + db.session.rollback() + raise DAODeleteFailedError(exception=ex) from ex + + @staticmethod + def delete_tags(tag_names: List[str]) -> None: + """ + deletes tags from a list of tag names + """ + tags_to_delete = [] + for name in tag_names: + tag_name = name.strip() + if not TagDAO.find_by_name(tag_name): + raise DAODeleteFailedError( + message=f"Tag with name {tag_name} does not exist." + ) + tags_to_delete.append(tag_name) + tag_objects = db.session.query(Tag).filter(Tag.name.in_(tags_to_delete)) + for tag in tag_objects: + try: + db.session.delete(tag) + db.session.commit() + except SQLAlchemyError as ex: # pragma: no cover + db.session.rollback() + raise DAODeleteFailedError(exception=ex) from ex + + @staticmethod + def get_by_name(name: str, type_: TagTypes = TagTypes.custom) -> Tag: + """ + returns a tag if one exists by that name, none otherwise. + important!: Creates a tag by that name if the tag is not found. + """ + tag = ( + db.session.query(Tag) + .filter(Tag.name == name, Tag.type == type_.name) + .first() + ) + if not tag: + tag = get_tag(name, db.session, type_) + return tag + + @staticmethod + def find_by_name(name: str) -> Tag: + """ + returns a tag if one exists by that name, none otherwise. + Does NOT create a tag if the tag is not found. + """ + return db.session.query(Tag).filter(Tag.name == name).first() + + @staticmethod + def find_tagged_object( + object_type: ObjectTypes, object_id: int, tag_id: int + ) -> TaggedObject: + """ + returns a tagged object if one exists by that name, none otherwise. + """ + return ( + db.session.query(TaggedObject) + .filter( + TaggedObject.tag_id == tag_id, + TaggedObject.object_id == object_id, + TaggedObject.object_type == object_type, + ) + .first() + ) + + @staticmethod + def get_tagged_objects_for_tags( + tags: Optional[List[str]] = None, obj_types: Optional[List[str]] = None + ) -> List[Dict[str, Any]]: + """ + returns a list of tagged objects filtered by tag names and object types + if no filters applied returns all tagged objects + """ + # id = fields.Int() + # type = fields.String() + # name = fields.String() + # url = fields.String() + # changed_on = fields.DateTime() + # created_by = fields.Nested(UserSchema) + # creator = fields.String( + + # filter types + + results: List[Dict[str, Any]] = [] + + # dashboards + if (not obj_types) or ("dashboard" in obj_types): + dashboards = ( + db.session.query(Dashboard) + .join( + TaggedObject, + and_( + TaggedObject.object_id == Dashboard.id, + TaggedObject.object_type == ObjectTypes.dashboard, + ), + ) + .join(Tag, TaggedObject.tag_id == Tag.id) + .filter(not tags or Tag.name.in_(tags)) + ) + + results.extend( + { + "id": obj.id, + "type": ObjectTypes.dashboard.name, + "name": obj.dashboard_title, + "url": obj.url, + "changed_on": obj.changed_on, + "created_by": obj.created_by_fk, + "creator": obj.creator(), + } + for obj in dashboards + ) + + # charts + if (not obj_types) or ("chart" in obj_types): + charts = ( + db.session.query(Slice) + .join( + TaggedObject, + and_( + TaggedObject.object_id == Slice.id, + TaggedObject.object_type == ObjectTypes.chart, + ), + ) + .join(Tag, TaggedObject.tag_id == Tag.id) + .filter(not tags or Tag.name.in_(tags)) + ) + results.extend( + { + "id": obj.id, + "type": ObjectTypes.chart.name, + "name": obj.slice_name, + "url": obj.url, + "changed_on": obj.changed_on, + "created_by": obj.created_by_fk, + "creator": obj.creator(), + } + for obj in charts + ) + + # saved queries + if (not obj_types) or ("query" in obj_types): + saved_queries = ( + db.session.query(SavedQuery) + .join( + TaggedObject, + and_( + TaggedObject.object_id == SavedQuery.id, + TaggedObject.object_type == ObjectTypes.query, + ), + ) + .join(Tag, TaggedObject.tag_id == Tag.id) + .filter(not tags or Tag.name.in_(tags)) + ) + results.extend( + { + "id": obj.id, + "type": ObjectTypes.query.name, + "name": obj.label, + "url": obj.url(), + "changed_on": obj.changed_on, + "created_by": obj.created_by_fk, + "creator": obj.creator(), + } + for obj in saved_queries + ) + return results diff --git a/superset/tags/exceptions.py b/superset/tags/exceptions.py new file mode 100644 index 0000000000000..d1d9005bb962c --- /dev/null +++ b/superset/tags/exceptions.py @@ -0,0 +1,29 @@ +# 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 flask_babel import lazy_gettext as _ +from marshmallow.validate import ValidationError + + +class InvalidTagNameError(ValidationError): + """ + Marshmallow validation error for invalid Tag name + """ + + def __init__(self) -> None: + super().__init__( + [_("Tag name is invalid (cannot contain ':')")], field_name="name" + ) diff --git a/superset/tags/models.py b/superset/tags/models.py index 89505146e2598..b30a214bd6acf 100644 --- a/superset/tags/models.py +++ b/superset/tags/models.py @@ -91,16 +91,22 @@ class TaggedObject(Model, AuditMixinNullable): __tablename__ = "tagged_object" id = Column(Integer, primary_key=True) tag_id = Column(Integer, ForeignKey("tag.id")) - object_id = Column(Integer) + object_id = Column( + Integer, + ForeignKey("dashboards.id"), + ForeignKey("slices.id"), + ForeignKey("saved_query.id"), + ) object_type = Column(Enum(ObjectTypes)) tag = relationship("Tag", backref="objects") def get_tag(name: str, session: Session, type_: TagTypes) -> Tag: - tag = session.query(Tag).filter_by(name=name, type=type_).one_or_none() + tag_name = name.strip() + tag = session.query(Tag).filter_by(name=tag_name, type=type_).one_or_none() if tag is None: - tag = Tag(name=name, type=type_) + tag = Tag(name=tag_name, type=type_) session.add(tag) session.commit() return tag @@ -269,7 +275,7 @@ def after_delete( cls, _mapper: Mapper, connection: Connection, target: FavStar ) -> None: session = Session(bind=connection) - name = "favorited_by:{0}".format(target.user_id) + name = f"favorited_by:{target.user_id}" query = ( session.query(TaggedObject.id) .join(Tag) diff --git a/superset/tags/schemas.py b/superset/tags/schemas.py new file mode 100644 index 0000000000000..2081adf69fa80 --- /dev/null +++ b/superset/tags/schemas.py @@ -0,0 +1,59 @@ +# 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 marshmallow import fields, Schema + +from superset.dashboards.schemas import UserSchema + +delete_tags_schema = {"type": "array", "items": {"type": "string"}} + +object_type_description = "A title for the tag." + +openapi_spec_methods_override = { + "get": {"get": {"description": "Get a tag detail information."}}, + "get_list": { + "get": { + "description": "Get a list of tags, use Rison or JSON query " + "parameters for filtering, sorting, pagination and " + " for selecting specific columns and metadata.", + } + }, + "info": { + "get": { + "description": "Several metadata information about tag API " "endpoints.", + } + }, +} + + +class TaggedObjectEntityResponseSchema(Schema): + id = fields.Int() + type = fields.String() + name = fields.String() + url = fields.String() + changed_on = fields.DateTime() + created_by = fields.Nested(UserSchema) + creator = fields.String() + + +class TagGetResponseSchema(Schema): + id = fields.Int() + name = fields.String() + type = fields.String() + + +class TagPostSchema(Schema): + tags = fields.List(fields.String()) diff --git a/superset/views/all_entities.py b/superset/views/all_entities.py new file mode 100644 index 0000000000000..4031d81d2129e --- /dev/null +++ b/superset/views/all_entities.py @@ -0,0 +1,71 @@ +# 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 __future__ import absolute_import, division, print_function, unicode_literals + +import logging + +from flask_appbuilder import expose +from flask_appbuilder.hooks import before_request +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import has_access +from jinja2.sandbox import SandboxedEnvironment +from werkzeug.exceptions import NotFound + +from superset import is_feature_enabled +from superset.jinja_context import ExtraCache +from superset.superset_typing import FlaskResponse +from superset.tags.models import Tag +from superset.views.base import SupersetModelView + +from .base import BaseSupersetView + +logger = logging.getLogger(__name__) + + +def process_template(content: str) -> str: + env = SandboxedEnvironment() + template = env.from_string(content) + context = { + "current_user_id": ExtraCache.current_user_id, + "current_username": ExtraCache.current_username, + } + return template.render(context) + + +class TaggedObjectsModelView(SupersetModelView): + route_base = "/superset/all_entities" + datamodel = SQLAInterface(Tag) + class_permission_name = "Tags" + + @has_access + @expose("/") + def list(self) -> FlaskResponse: + if not is_feature_enabled("TAGGING_SYSTEM"): + return super().list() + + return super().render_app_template() + + +class TaggedObjectView(BaseSupersetView): + @staticmethod + def is_enabled() -> bool: + return is_feature_enabled("TAGGING_SYSTEM") + + @before_request + def ensure_enabled(self) -> None: + if not self.is_enabled(): + raise NotFound() diff --git a/superset/views/base_api.py b/superset/views/base_api.py index 29bac574aca56..e5c36be032376 100644 --- a/superset/views/base_api.py +++ b/superset/views/base_api.py @@ -31,6 +31,7 @@ from sqlalchemy import and_, distinct, func from sqlalchemy.orm.query import Query +from superset.connectors.sqla.models import SqlaTable from superset.exceptions import InvalidPayloadFormatError from superset.extensions import db, event_logger, security_manager, stats_logger_manager from superset.models.core import FavStar @@ -39,6 +40,7 @@ from superset.schemas import error_payload_content from superset.sql_lab import Query as SqllabQuery from superset.superset_typing import FlaskResponse +from superset.tags.models import Tag from superset.utils.core import get_user_id, time_function from superset.views.base import handle_api_exception @@ -157,6 +159,29 @@ def apply(self, query: Query, value: Any) -> Query: return query.filter(and_(~self.model.id.in_(users_favorite_query))) +class BaseTagFilter(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 tagged") + arg_name = "" + class_name = "" + """ The Tag class_name to user """ + model: Type[Union[Dashboard, Slice, SqllabQuery, SqlaTable]] = Dashboard + """ The SQLAlchemy model """ + + def apply(self, query: Query, value: Any) -> Query: + ilike_value = f"%{value}%" + tags_query = ( + db.session.query(self.model.id) + .join(self.model.tags) + .filter(Tag.name.ilike(ilike_value)) + ) + return query.filter(self.model.id.in_(tags_query)) + + class BaseSupersetApiMixin: csrf_exempt = False diff --git a/superset/views/tags.py b/superset/views/tags.py index 985d26179fe28..44027823cbce6 100644 --- a/superset/views/tags.py +++ b/superset/views/tags.py @@ -16,28 +16,26 @@ # under the License. from __future__ import absolute_import, division, print_function, unicode_literals -from typing import Any, Dict, List +import logging import simplejson as json -from flask import request, Response from flask_appbuilder import expose from flask_appbuilder.hooks import before_request -from flask_appbuilder.security.decorators import has_access_api +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import has_access, has_access_api from jinja2.sandbox import SandboxedEnvironment -from sqlalchemy import and_, func from werkzeug.exceptions import NotFound from superset import db, is_feature_enabled, utils -from superset.connectors.sqla.models import SqlaTable from superset.jinja_context import ExtraCache -from superset.models.dashboard import Dashboard -from superset.models.slice import Slice -from superset.models.sql_lab import SavedQuery from superset.superset_typing import FlaskResponse -from superset.tags.models import ObjectTypes, Tag, TaggedObject, TagTypes +from superset.tags.models import Tag +from superset.views.base import SupersetModelView from .base import BaseSupersetView, json_success +logger = logging.getLogger(__name__) + def process_template(content: str) -> str: env = SandboxedEnvironment() @@ -49,6 +47,20 @@ def process_template(content: str) -> str: return template.render(context) +class TagModelView(SupersetModelView): + route_base = "/superset/tags" + datamodel = SQLAInterface(Tag) + class_permission_name = "Tags" + + @has_access + @expose("/") + def list(self) -> FlaskResponse: + if not is_feature_enabled("TAGGING_SYSTEM"): + return super().list() + + return super().render_app_template() + + class TagView(BaseSupersetView): @staticmethod def is_enabled() -> bool: @@ -60,210 +72,18 @@ def ensure_enabled(self) -> None: raise NotFound() @has_access_api - @expose("/tags/suggestions/", methods=["GET"]) - def suggestions(self) -> FlaskResponse: # pylint: disable=no-self-use - query = ( - db.session.query(TaggedObject) - .join(Tag) - .with_entities(TaggedObject.tag_id, Tag.name) - .group_by(TaggedObject.tag_id, Tag.name) - .order_by(func.count().desc()) - .all() - ) - tags = [{"id": id, "name": name} for id, name in query] - return json_success(json.dumps(tags)) - - @has_access_api - @expose("/tags///", methods=["GET"]) - def get( # pylint: disable=no-self-use - self, object_type: ObjectTypes, object_id: int - ) -> FlaskResponse: - """List all tags a given object has.""" - if object_id == 0: - return json_success(json.dumps([])) - - query = db.session.query(TaggedObject).filter( - and_( - TaggedObject.object_type == object_type, - TaggedObject.object_id == object_id, - ) - ) - tags = [{"id": obj.tag.id, "name": obj.tag.name} for obj in query] - return json_success(json.dumps(tags)) - - @has_access_api - @expose("/tags///", methods=["POST"]) - def post( # pylint: disable=no-self-use - self, object_type: ObjectTypes, object_id: int - ) -> FlaskResponse: - """Add new tags to an object.""" - if object_id == 0: - return Response(status=404) - - tagged_objects = [] - for name in request.get_json(force=True): - if ":" in name: - type_name = name.split(":", 1)[0] - type_ = TagTypes[type_name] - else: - type_ = TagTypes.custom - - tag = db.session.query(Tag).filter_by(name=name, type=type_).first() - if not tag: - tag = Tag(name=name, type=type_) - - tagged_objects.append( - TaggedObject(object_id=object_id, object_type=object_type, tag=tag) - ) - - db.session.add_all(tagged_objects) - db.session.commit() - - return Response(status=201) # 201 CREATED - - @has_access_api - @expose("/tags///", methods=["DELETE"]) - def delete( # pylint: disable=no-self-use - self, object_type: ObjectTypes, object_id: int - ) -> FlaskResponse: - """Remove tags from an object.""" - tag_names = request.get_json(force=True) - if not tag_names: - return Response(status=403) - - db.session.query(TaggedObject).filter( - and_( - TaggedObject.object_type == object_type, - TaggedObject.object_id == object_id, - ), - TaggedObject.tag.has(Tag.name.in_(tag_names)), - ).delete(synchronize_session=False) - db.session.commit() - - return Response(status=204) # 204 NO CONTENT - - @has_access_api - @expose("/tagged_objects/", methods=["GET", "POST"]) - def tagged_objects(self) -> FlaskResponse: # pylint: disable=no-self-use - tags = [ - process_template(tag) - for tag in request.args.get("tags", "").split(",") - if tag + @expose("/tags/", methods=["GET"]) + def tags(self) -> FlaskResponse: # pylint: disable=no-self-use + query = db.session.query(Tag).all() + results = [ + { + "id": obj.id, + "type": obj.type.name, + "name": obj.name, + "changed_on": obj.changed_on, + "changed_by": obj.changed_by_fk, + "created_by": obj.created_by_fk, + } + for obj in query ] - if not tags: - return json_success(json.dumps([])) - - # filter types - types = [type_ for type_ in request.args.get("types", "").split(",") if type_] - - results: List[Dict[str, Any]] = [] - - # dashboards - if not types or "dashboard" in types: - dashboards = ( - db.session.query(Dashboard) - .join( - TaggedObject, - and_( - TaggedObject.object_id == Dashboard.id, - TaggedObject.object_type == ObjectTypes.dashboard, - ), - ) - .join(Tag, TaggedObject.tag_id == Tag.id) - .filter(Tag.name.in_(tags)) - ) - results.extend( - { - "id": obj.id, - "type": ObjectTypes.dashboard.name, - "name": obj.dashboard_title, - "url": obj.url, - "changed_on": obj.changed_on, - "created_by": obj.created_by_fk, - "creator": obj.creator(), - } - for obj in dashboards - ) - - # charts - if not types or "chart" in types: - charts = ( - db.session.query(Slice) - .join( - TaggedObject, - and_( - TaggedObject.object_id == Slice.id, - TaggedObject.object_type == ObjectTypes.chart, - ), - ) - .join(Tag, TaggedObject.tag_id == Tag.id) - .filter(Tag.name.in_(tags)) - ) - results.extend( - { - "id": obj.id, - "type": ObjectTypes.chart.name, - "name": obj.slice_name, - "url": obj.url, - "changed_on": obj.changed_on, - "created_by": obj.created_by_fk, - "creator": obj.creator(), - } - for obj in charts - ) - - # saved queries - if not types or "query" in types: - saved_queries = ( - db.session.query(SavedQuery) - .join( - TaggedObject, - and_( - TaggedObject.object_id == SavedQuery.id, - TaggedObject.object_type == ObjectTypes.query, - ), - ) - .join(Tag, TaggedObject.tag_id == Tag.id) - .filter(Tag.name.in_(tags)) - ) - results.extend( - { - "id": obj.id, - "type": ObjectTypes.query.name, - "name": obj.label, - "url": obj.url(), - "changed_on": obj.changed_on, - "created_by": obj.created_by_fk, - "creator": obj.creator(), - } - for obj in saved_queries - ) - - # datasets - if not types or "dataset" in types: - datasets = ( - db.session.query(SqlaTable) - .join( - TaggedObject, - and_( - TaggedObject.object_id == SqlaTable.id, - TaggedObject.object_type == ObjectTypes.dataset, - ), - ) - .join(Tag, TaggedObject.tag_id == Tag.id) - .filter(Tag.name.in_(tags)) - ) - results.extend( - { - "id": obj.id, - "type": ObjectTypes.dataset.name, - "name": obj.table_name, - "url": obj.sql_url(), - "changed_on": obj.changed_on, - "created_by": obj.created_by_fk, - "creator": obj.creator(), - } - for obj in datasets - ) - return json_success(json.dumps(results, default=utils.core.json_int_dttm_ser)) diff --git a/tests/integration_tests/tagging_tests.py b/tests/integration_tests/tagging_tests.py index 4ee10041d2c53..71fb7e4e4e89d 100644 --- a/tests/integration_tests/tagging_tests.py +++ b/tests/integration_tests/tagging_tests.py @@ -42,18 +42,6 @@ def clear_tagged_object_table(self): db.session.query(TaggedObject).delete() db.session.commit() - @with_feature_flags(TAGGING_SYSTEM=False) - def test_tag_view_disabled(self): - self.login("admin") - response = self.client.get("/tagview/tags/suggestions/") - self.assertEqual(404, response.status_code) - - @with_feature_flags(TAGGING_SYSTEM=True) - def test_tag_view_enabled(self): - self.login("admin") - response = self.client.get("/tagview/tags/suggestions/") - self.assertNotEqual(404, response.status_code) - @pytest.mark.usefixtures("with_tagging_system_feature") def test_dataset_tagging(self): """ diff --git a/tests/integration_tests/tags/__init__.py b/tests/integration_tests/tags/__init__.py new file mode 100644 index 0000000000000..13a83393a9124 --- /dev/null +++ b/tests/integration_tests/tags/__init__.py @@ -0,0 +1,16 @@ +# 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. diff --git a/tests/integration_tests/tags/api_tests.py b/tests/integration_tests/tags/api_tests.py new file mode 100644 index 0000000000000..7bf21da4fcd71 --- /dev/null +++ b/tests/integration_tests/tags/api_tests.py @@ -0,0 +1,377 @@ +# 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. +# isort:skip_file +"""Unit tests for Superset""" +from datetime import datetime, timedelta +import json +import random +import string + +import pytest +import prison +from sqlalchemy.sql import func +from superset.models.dashboard import Dashboard +from superset.models.slice import Slice +from superset.models.sql_lab import SavedQuery + +import tests.integration_tests.test_app +from superset import db, security_manager +from superset.common.db_query_status import QueryStatus +from superset.models.core import Database +from superset.utils.database import get_example_database, get_main_database +from superset.tags.models import ObjectTypes, Tag, TagTypes, TaggedObject +from tests.integration_tests.fixtures.birth_names_dashboard import ( + load_birth_names_dashboard_with_slices, + load_birth_names_data, +) +from tests.integration_tests.fixtures.world_bank_dashboard import ( + load_world_bank_dashboard_with_slices, + load_world_bank_data, +) +from tests.integration_tests.fixtures.tags import with_tagging_system_feature +from tests.integration_tests.base_tests import SupersetTestCase + +TAGS_FIXTURE_COUNT = 10 + +TAGS_LIST_COLUMNS = [ + "id", + "name", + "type", + "changed_by.first_name", + "changed_by.last_name", + "changed_on_delta_humanized", + "created_by.first_name", + "created_by.last_name", +] + + +class TestTagApi(SupersetTestCase): + def insert_tag( + self, + name: str, + tag_type: str, + ) -> Tag: + tag_name = name.strip() + tag = Tag( + name=tag_name, + type=tag_type, + ) + db.session.add(tag) + db.session.commit() + return tag + + def insert_tagged_object( + self, + tag_id: int, + object_id: int, + object_type: ObjectTypes, + ) -> TaggedObject: + tag = db.session.query(Tag).filter(Tag.id == tag_id).first() + tagged_object = TaggedObject( + tag=tag, object_id=object_id, object_type=object_type.name + ) + db.session.add(tagged_object) + db.session.commit() + return tagged_object + + @pytest.fixture() + def create_tags(self): + with self.create_app().app_context(): + # clear tags table + tags = db.session.query(Tag) + for tag in tags: + db.session.delete(tag) + db.session.commit() + tags = [] + for cx in range(TAGS_FIXTURE_COUNT): + tags.append( + self.insert_tag( + name=f"example_tag_{cx}", + tag_type="custom", + ) + ) + yield + + # rollback changes + for tag in tags: + db.session.delete(tag) + db.session.commit() + + def test_get_tag(self): + """ + Query API: Test get query + """ + tag = self.insert_tag( + name="test get tag", + tag_type="custom", + ) + self.login(username="admin") + uri = f"api/v1/tag/{tag.id}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + expected_result = { + "changed_by": None, + "changed_on_delta_humanized": "now", + "created_by": None, + "id": tag.id, + "name": "test get tag", + "type": TagTypes.custom.value, + } + data = json.loads(rv.data.decode("utf-8")) + for key, value in expected_result.items(): + self.assertEqual(value, data["result"][key]) + # rollback changes + db.session.delete(tag) + db.session.commit() + + def test_get_tag_not_found(self): + """ + Query API: Test get query not found + """ + tag = self.insert_tag(name="test tag", tag_type="custom") + max_id = db.session.query(func.max(Tag.id)).scalar() + self.login(username="admin") + uri = f"api/v1/tag/{max_id + 1}" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 404) + # cleanup + db.session.delete(tag) + db.session.commit() + + @pytest.mark.usefixtures("create_tags") + def test_get_list_tag(self): + """ + Query API: Test get list query + """ + self.login(username="admin") + uri = "api/v1/tag/" + rv = self.client.get(uri) + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == TAGS_FIXTURE_COUNT + # check expected columns + assert data["list_columns"] == TAGS_LIST_COLUMNS + + # test add tagged objects + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + def test_add_tagged_objects(self): + self.login(username="admin") + # clean up tags and tagged objects + tags = db.session.query(Tag) + for tag in tags: + db.session.delete(tag) + db.session.commit() + tagged_objects = db.session.query(TaggedObject) + for tagged_object in tagged_objects: + db.session.delete(tagged_object) + db.session.commit() + dashboard = ( + db.session.query(Dashboard) + .filter(Dashboard.dashboard_title == "World Bank's Data") + .first() + ) + dashboard_id = dashboard.id + dashboard_type = ObjectTypes.dashboard.value + uri = f"api/v1/tag/{dashboard_type}/{dashboard_id}/" + example_tag_names = ["example_tag_1", "example_tag_2"] + data = {"properties": {"tags": example_tag_names}} + rv = self.client.post(uri, json=data, follow_redirects=True) + # successful request + self.assertEqual(rv.status_code, 201) + # check that tags were created in database + tags = db.session.query(Tag).filter(Tag.name.in_(example_tag_names)) + self.assertEqual(tags.count(), 2) + # check that tagged objects were created + tag_ids = [tags[0].id, tags[1].id] + tagged_objects = db.session.query(TaggedObject).filter( + TaggedObject.tag_id.in_(tag_ids), + TaggedObject.object_id == dashboard_id, + TaggedObject.object_type == ObjectTypes.dashboard, + ) + assert tagged_objects.count() == 2 + # clean up tags and tagged objects + for tagged_object in tagged_objects: + db.session.delete(tagged_object) + db.session.commit() + for tag in tags: + db.session.delete(tag) + db.session.commit() + + # test delete tagged object + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @pytest.mark.usefixtures("create_tags") + def test_delete_tagged_objects(self): + self.login(username="admin") + dashboard_id = 1 + dashboard_type = ObjectTypes.dashboard + tag_names = ["example_tag_1", "example_tag_2"] + tags = db.session.query(Tag).filter(Tag.name.in_(tag_names)) + assert tags.count() == 2 + self.insert_tagged_object( + tag_id=tags.first().id, object_id=dashboard_id, object_type=dashboard_type + ) + self.insert_tagged_object( + tag_id=tags[1].id, object_id=dashboard_id, object_type=dashboard_type + ) + tagged_object = ( + db.session.query(TaggedObject) + .filter( + TaggedObject.tag_id == tags.first().id, + TaggedObject.object_id == dashboard_id, + TaggedObject.object_type == dashboard_type.name, + ) + .first() + ) + other_tagged_object = ( + db.session.query(TaggedObject) + .filter( + TaggedObject.tag_id == tags[1].id, + TaggedObject.object_id == dashboard_id, + TaggedObject.object_type == dashboard_type.name, + ) + .first() + ) + assert tagged_object is not None + uri = f"api/v1/tag/{dashboard_type.value}/{dashboard_id}/{tags.first().name}" + rv = self.client.delete(uri, follow_redirects=True) + # successful request + self.assertEqual(rv.status_code, 200) + # ensure that tagged object no longer exists + tagged_object = ( + db.session.query(TaggedObject) + .filter( + TaggedObject.tag_id == tags.first().id, + TaggedObject.object_id == dashboard_id, + TaggedObject.object_type == dashboard_type.name, + ) + .first() + ) + assert not tagged_object + # ensure the other tagged objects still exist + other_tagged_object = ( + db.session.query(TaggedObject) + .filter( + TaggedObject.object_id == dashboard_id, + TaggedObject.object_type == dashboard_type.name, + TaggedObject.tag_id == tags[1].id, + ) + .first() + ) + assert other_tagged_object is not None + # clean up tagged object + db.session.delete(other_tagged_object) + + # test get objects + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @pytest.mark.usefixtures("create_tags") + def test_get_objects_by_tag(self): + self.login(username="admin") + dashboard = ( + db.session.query(Dashboard) + .filter(Dashboard.dashboard_title == "World Bank's Data") + .first() + ) + dashboard_id = dashboard.id + dashboard_type = ObjectTypes.dashboard + tag_names = ["example_tag_1", "example_tag_2"] + tags = db.session.query(Tag).filter(Tag.name.in_(tag_names)) + for tag in tags: + self.insert_tagged_object( + tag_id=tag.id, object_id=dashboard_id, object_type=dashboard_type + ) + tagged_objects = db.session.query(TaggedObject).filter( + TaggedObject.tag_id.in_([tag.id for tag in tags]), + TaggedObject.object_id == dashboard_id, + TaggedObject.object_type == dashboard_type.name, + ) + self.assertEqual(tagged_objects.count(), 2) + uri = f'api/v1/tag/get_objects/?tags={",".join(tag_names)}' + rv = self.client.get(uri) + # successful request + self.assertEqual(rv.status_code, 200) + fetched_objects = rv.json["result"] + self.assertEqual(len(fetched_objects), 1) + self.assertEqual(fetched_objects[0]["id"], dashboard_id) + # clean up tagged object + tagged_objects.delete() + + # test get all objects + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("load_birth_names_dashboard_with_slices") + @pytest.mark.usefixtures("create_tags") + def test_get_all_objects(self): + self.login(username="admin") + # tag the dashboard with id 1 + dashboard = ( + db.session.query(Dashboard) + .filter(Dashboard.dashboard_title == "World Bank's Data") + .first() + ) + dashboard_id = dashboard.id + dashboard_type = ObjectTypes.dashboard + tag_names = ["example_tag_1", "example_tag_2"] + tags = db.session.query(Tag).filter(Tag.name.in_(tag_names)) + for tag in tags: + self.insert_tagged_object( + tag_id=tag.id, object_id=dashboard_id, object_type=dashboard_type + ) + tagged_objects = db.session.query(TaggedObject).filter( + TaggedObject.tag_id.in_([tag.id for tag in tags]), + TaggedObject.object_id == dashboard_id, + TaggedObject.object_type == dashboard_type.name, + ) + self.assertEqual(tagged_objects.count(), 2) + self.assertEqual(tagged_objects.first().object_id, dashboard_id) + uri = "api/v1/tag/get_objects/" + rv = self.client.get(uri) + # successful request + self.assertEqual(rv.status_code, 200) + fetched_objects = rv.json["result"] + # check that the dashboard object was fetched + assert dashboard_id in [obj["id"] for obj in fetched_objects] + # clean up tagged object + tagged_objects.delete() + + # test delete tags + @pytest.mark.usefixtures("create_tags") + def test_delete_tags(self): + self.login(username="admin") + # check that tags exist in the database + example_tag_names = ["example_tag_1", "example_tag_2", "example_tag_3"] + tags = db.session.query(Tag).filter(Tag.name.in_(example_tag_names)) + self.assertEqual(tags.count(), 3) + # delete the first tag + uri = f"api/v1/tag/?q={prison.dumps(example_tag_names[:1])}" + rv = self.client.delete(uri, follow_redirects=True) + # successful request + self.assertEqual(rv.status_code, 200) + # check that tag does not exist in the database + tag = db.session.query(Tag).filter(Tag.name == example_tag_names[0]).first() + assert tag is None + tags = db.session.query(Tag).filter(Tag.name.in_(example_tag_names)) + self.assertEqual(tags.count(), 2) + # delete multiple tags + uri = f"api/v1/tag/?q={prison.dumps(example_tag_names[1:])}" + rv = self.client.delete(uri, follow_redirects=True) + # successful request + self.assertEqual(rv.status_code, 200) + # check that tags are all gone + tags = db.session.query(Tag).filter(Tag.name.in_(example_tag_names)) + self.assertEqual(tags.count(), 0) diff --git a/tests/integration_tests/tags/commands_tests.py b/tests/integration_tests/tags/commands_tests.py new file mode 100644 index 0000000000000..8f44d2ebda0dd --- /dev/null +++ b/tests/integration_tests/tags/commands_tests.py @@ -0,0 +1,175 @@ +# 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. +import itertools +import json +from unittest.mock import MagicMock, patch + +import pytest +import yaml +from werkzeug.utils import secure_filename + +from superset import db, security_manager +from superset.commands.exceptions import CommandInvalidError +from superset.commands.importers.exceptions import IncorrectVersionError +from superset.connectors.sqla.models import SqlaTable +from superset.dashboards.commands.exceptions import DashboardNotFoundError +from superset.dashboards.commands.export import ( + append_charts, + ExportDashboardsCommand, + get_default_position, +) +from superset.dashboards.commands.importers import v0, v1 +from superset.models.core import Database +from superset.models.dashboard import Dashboard +from superset.models.slice import Slice +from superset.tags.commands.create import CreateCustomTagCommand +from superset.tags.commands.delete import DeleteTaggedObjectCommand, DeleteTagsCommand +from superset.tags.models import ObjectTypes, Tag, TaggedObject, TagTypes +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.importexport import ( + chart_config, + dashboard_config, + dashboard_export, + dashboard_metadata_config, + database_config, + dataset_config, + dataset_metadata_config, +) +from tests.integration_tests.fixtures.tags import with_tagging_system_feature +from tests.integration_tests.fixtures.world_bank_dashboard import ( + load_world_bank_dashboard_with_slices, + load_world_bank_data, +) + + +# test create command +class TestCreateCustomTagCommand(SupersetTestCase): + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + def test_create_custom_tag_command(self): + example_dashboard = ( + db.session.query(Dashboard).filter_by(slug="world_health").one() + ) + example_tags = ["create custom tag example 1", "create custom tag example 2"] + command = CreateCustomTagCommand( + ObjectTypes.dashboard.value, example_dashboard.id, example_tags + ) + command.run() + + created_tags = ( + db.session.query(Tag) + .join(TaggedObject) + .filter( + TaggedObject.object_id == example_dashboard.id, + Tag.type == TagTypes.custom, + ) + .all() + ) + assert example_tags == [tag.name for tag in created_tags] + + # cleanup + tags = db.session.query(Tag).filter(Tag.name.in_(example_tags)) + db.session.query(TaggedObject).filter( + TaggedObject.tag_id.in_([tag.id for tag in tags]) + ).delete() + tags.delete() + db.session.commit() + + +# test delete tags command +class TestDeleteTagsCommand(SupersetTestCase): + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + def test_delete_tags_command(self): + example_dashboard = ( + db.session.query(Dashboard) + .filter_by(dashboard_title="World Bank's Data") + .one() + ) + example_tags = ["create custom tag example 1", "create custom tag example 2"] + command = CreateCustomTagCommand( + ObjectTypes.dashboard.value, example_dashboard.id, example_tags + ) + command.run() + + created_tags = ( + db.session.query(Tag) + .join(TaggedObject) + .filter( + TaggedObject.object_id == example_dashboard.id, + Tag.type == TagTypes.custom, + ) + .all() + ) + assert example_tags == [tag.name for tag in created_tags] + + command = DeleteTagsCommand(example_tags) + command.run() + tags = db.session.query(Tag).filter(Tag.name.in_(example_tags)) + assert tags.count() == 0 + + +# test delete tagged objects command +class TestDeleteTaggedObjectCommand(SupersetTestCase): + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + def test_delete_tags_command(self): + # create tagged objects + example_dashboard = ( + db.session.query(Dashboard).filter_by(slug="world_health").one() + ) + example_tags = ["create custom tag example 1", "create custom tag example 2"] + command = CreateCustomTagCommand( + ObjectTypes.dashboard.value, example_dashboard.id, example_tags + ) + command.run() + + tagged_objects = ( + db.session.query(TaggedObject) + .join(Tag) + .filter( + TaggedObject.object_id == example_dashboard.id, + TaggedObject.object_type == ObjectTypes.dashboard.name, + Tag.name.in_(example_tags), + ) + ) + assert tagged_objects.count() == 2 + # delete one of the tagged objects + command = DeleteTaggedObjectCommand( + object_type=ObjectTypes.dashboard.value, + object_id=example_dashboard.id, + tag=example_tags[0], + ) + command.run() + tagged_objects = ( + db.session.query(TaggedObject) + .join(Tag) + .filter( + TaggedObject.object_id == example_dashboard.id, + TaggedObject.object_type == ObjectTypes.dashboard.name, + Tag.name.in_(example_tags), + ) + ) + assert tagged_objects.count() == 1 + + # cleanup + tags = db.session.query(Tag).filter(Tag.name.in_(example_tags)) + db.session.query(TaggedObject).filter( + TaggedObject.tag_id.in_([tag.id for tag in tags]) + ).delete() + tags.delete() + db.session.commit() diff --git a/tests/integration_tests/tags/dao_tests.py b/tests/integration_tests/tags/dao_tests.py new file mode 100644 index 0000000000000..0234b2c8c2dfd --- /dev/null +++ b/tests/integration_tests/tags/dao_tests.py @@ -0,0 +1,299 @@ +# 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. +# isort:skip_file +import copy +import json +from operator import and_ +import time +from unittest.mock import patch +import pytest +from superset.dao.exceptions import DAOCreateFailedError, DAOException +from superset.models.slice import Slice +from superset.models.sql_lab import SavedQuery +from superset.tags.dao import TagDAO +from superset.tags.exceptions import InvalidTagNameError +from superset.tags.models import ObjectTypes, Tag, TaggedObject +from tests.integration_tests.tags.api_tests import TAGS_FIXTURE_COUNT + +import tests.integration_tests.test_app # pylint: disable=unused-import +from superset import db, security_manager +from superset.dashboards.dao import DashboardDAO +from superset.models.dashboard import Dashboard +from tests.integration_tests.base_tests import SupersetTestCase +from tests.integration_tests.fixtures.world_bank_dashboard import ( + load_world_bank_dashboard_with_slices, + load_world_bank_data, +) +from tests.integration_tests.fixtures.tags import with_tagging_system_feature + + +class TestTagsDAO(SupersetTestCase): + def insert_tag( + self, + name: str, + tag_type: str, + ) -> Tag: + tag_name = name.strip() + tag = Tag( + name=tag_name, + type=tag_type, + ) + db.session.add(tag) + db.session.commit() + return tag + + def insert_tagged_object( + self, + tag_id: int, + object_id: int, + object_type: ObjectTypes, + ) -> TaggedObject: + tag = db.session.query(Tag).filter(Tag.id == tag_id).first() + tagged_object = TaggedObject( + tag=tag, object_id=object_id, object_type=object_type.name + ) + db.session.add(tagged_object) + db.session.commit() + return tagged_object + + @pytest.fixture() + def create_tags(self): + with self.create_app().app_context(): + # clear tags table + tags = db.session.query(Tag) + for tag in tags: + db.session.delete(tag) + db.session.commit() + db.session.commit() + tags = [] + for cx in range(TAGS_FIXTURE_COUNT): + tags.append( + self.insert_tag( + name=f"example_tag_{cx}", + tag_type="custom", + ) + ) + yield tags + db.session.commit() + + @pytest.fixture() + def create_tagged_objects(self): + with self.create_app().app_context(): + # clear tags table + tags = db.session.query(Tag) + for tag in tags: + db.session.delete(tag) + db.session.commit() + tags = [] + for cx in range(TAGS_FIXTURE_COUNT): + tags.append( + self.insert_tag( + name=f"example_tag_{cx}", + tag_type="custom", + ) + ) + # clear tagged objects table + tagged_objects = db.session.query(TaggedObject) + for tagged_obj in tagged_objects: + db.session.delete(tagged_obj) + db.session.commit() + tagged_objects = [] + dashboard_id = 1 + for tag in tags: + tagged_objects.append( + self.insert_tagged_object( + object_id=dashboard_id, + object_type=ObjectTypes.dashboard, + tag_id=tag.id, + ) + ) + yield tagged_objects + db.session.commit() + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + # test create tag + def test_create_tagged_objects(self): + # test that a tag cannot be added if it has ':' in it + with pytest.raises(DAOCreateFailedError): + TagDAO.create_custom_tagged_objects( + object_type=ObjectTypes.dashboard.name, + object_id=1, + tag_names=["invalid:example tag 1"], + ) + + # test that a tag can be added if it has a valid name + TagDAO.create_custom_tagged_objects( + object_type=ObjectTypes.dashboard.name, + object_id=1, + tag_names=["example tag 1"], + ) + # check if tag exists + assert db.session.query(Tag).filter(Tag.name == "example tag 1").first() + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + @pytest.mark.usefixtures("create_tags") + # test get objects from tag + def test_get_objects_from_tag(self): + # create tagged objects + dashboard = ( + db.session.query(Dashboard) + .filter(Dashboard.dashboard_title == "World Bank's Data") + .first() + ) + dashboard_id = dashboard.id + tag = db.session.query(Tag).filter_by(name="example_tag_1").one() + self.insert_tagged_object( + object_id=dashboard_id, object_type=ObjectTypes.dashboard, tag_id=tag.id + ) + # get objects + tagged_objects = TagDAO.get_tagged_objects_for_tags( + ["example_tag_1", "example_tag_2"] + ) + assert len(tagged_objects) == 1 + + # test get objects from tag with type + tagged_objects = TagDAO.get_tagged_objects_for_tags( + ["example_tag_1", "example_tag_2"], obj_types=["dashboard", "chart"] + ) + assert len(tagged_objects) == 1 + tagged_objects = TagDAO.get_tagged_objects_for_tags( + ["example_tag_1", "example_tag_2"], obj_types=["chart"] + ) + assert len(tagged_objects) == 0 + # test get all objects + num_charts = ( + db.session.query(Slice) + .join( + TaggedObject, + and_( + TaggedObject.object_id == Slice.id, + TaggedObject.object_type == ObjectTypes.chart, + ), + ) + .distinct(Slice.id) + .count() + ) + num_charts_and_dashboards = ( + db.session.query(Dashboard) + .join( + TaggedObject, + and_( + TaggedObject.object_id == Dashboard.id, + TaggedObject.object_type == ObjectTypes.dashboard, + ), + ) + .distinct(Dashboard.id) + .count() + + num_charts + ) + # gets all tagged objects of type dashboard and chart + tagged_objects = TagDAO.get_tagged_objects_for_tags( + obj_types=["dashboard", "chart"] + ) + assert len(tagged_objects) == num_charts_and_dashboards + # test objects are retrieved by type + tagged_objects = TagDAO.get_tagged_objects_for_tags(obj_types=["chart"]) + assert len(tagged_objects) == num_charts + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + @pytest.mark.usefixtures("create_tagged_objects") + def test_find_tagged_object(self): + tag = db.session.query(Tag).filter(Tag.name == "example_tag_1").first() + tagged_object = TagDAO.find_tagged_object( + object_id=1, object_type=ObjectTypes.dashboard.name, tag_id=tag.id + ) + assert tagged_object is not None + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + @pytest.mark.usefixtures("create_tagged_objects") + def test_find_by_name(self): + # test tag can be found + tag = TagDAO.find_by_name("example_tag_1") + assert tag is not None + # tag that doesnt exist + tag = TagDAO.find_by_name("invalid_tag_1") + assert tag is None + # tag was not created + assert db.session.query(Tag).filter(Tag.name == "invalid_tag_1").first() is None + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + @pytest.mark.usefixtures("create_tagged_objects") + def test_get_by_name(self): + # test tag can be found + tag = TagDAO.get_by_name("example_tag_1") + assert tag is not None + # tag that doesnt exist is added + tag = TagDAO.get_by_name("invalid_tag_1") + assert tag is not None + # tag was created + tag = db.session.query(Tag).filter(Tag.name == "invalid_tag_1").first() + assert tag is not None + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + @pytest.mark.usefixtures("create_tags") + def test_delete_tags(self): + tag_names = ["example_tag_1", "example_tag_2"] + for tag_name in tag_names: + tag = db.session.query(Tag).filter(Tag.name == tag_name).first() + assert tag is not None + + TagDAO.delete_tags(tag_names) + + for tag_name in tag_names: + tag = db.session.query(Tag).filter(Tag.name == tag_name).first() + assert tag is None + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + @pytest.mark.usefixtures("create_tagged_objects") + def test_delete_tagged_object(self): + tag = db.session.query(Tag).filter(Tag.name == "example_tag_1").first() + tagged_object = ( + db.session.query(TaggedObject) + .filter( + TaggedObject.tag_id == tag.id, + TaggedObject.object_id == 1, + TaggedObject.object_type == ObjectTypes.dashboard.name, + ) + .first() + ) + assert tagged_object is not None + TagDAO.delete_tagged_object( + object_type=ObjectTypes.dashboard.name, object_id=1, tag_name=tag.name + ) + tagged_object = ( + db.session.query(TaggedObject) + .filter( + TaggedObject.tag_id == tag.id, + TaggedObject.object_id == 1, + TaggedObject.object_type == ObjectTypes.dashboard.name, + ) + .first() + ) + assert tagged_object is None + + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") + @pytest.mark.usefixtures("with_tagging_system_feature") + def test_validate_tag_name(self): + assert TagDAO.validate_tag_name("example_tag_name") is True + assert TagDAO.validate_tag_name("invalid:tag_name") is False