You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by hu...@apache.org on 2023/08/25 19:16:41 UTC
[superset] branch master updated: feat: Update Tags CRUD API (#24839)
This is an automated email from the ASF dual-hosted git repository.
hugh pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 69fb309ec3 feat: Update Tags CRUD API (#24839)
69fb309ec3 is described below
commit 69fb309ec3494307854ecd2df91dc65b65f4c516
Author: Hugh A. Miles II <hu...@gmail.com>
AuthorDate: Fri Aug 25 21:16:35 2023 +0200
feat: Update Tags CRUD API (#24839)
---
.../src/features/tags/TagModal.test.tsx | 46 +++
superset-frontend/src/features/tags/TagModal.tsx | 321 +++++++++++++++++++++
superset-frontend/src/features/tags/tags.ts | 10 +
superset-frontend/src/pages/Tags/index.tsx | 77 ++++-
superset-frontend/src/views/CRUD/types.ts | 2 +
superset/daos/tag.py | 44 +++
superset/tags/api.py | 141 ++++++++-
superset/tags/commands/create.py | 46 ++-
superset/tags/commands/exceptions.py | 8 +-
superset/tags/commands/update.py | 78 +++++
superset/tags/schemas.py | 16 +-
tests/integration_tests/tags/api_tests.py | 44 +++
tests/unit_tests/dao/tag_test.py | 28 ++
tests/unit_tests/tags/__init__.py | 0
tests/unit_tests/tags/commands/create_test.py | 110 +++++++
tests/unit_tests/tags/commands/update_test.py | 160 ++++++++++
16 files changed, 1109 insertions(+), 22 deletions(-)
diff --git a/superset-frontend/src/features/tags/TagModal.test.tsx b/superset-frontend/src/features/tags/TagModal.test.tsx
new file mode 100644
index 0000000000..a033b44cec
--- /dev/null
+++ b/superset-frontend/src/features/tags/TagModal.test.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { render, screen } from 'spec/helpers/testing-library';
+import TagModal from 'src/features/tags/TagModal';
+import fetchMock from 'fetch-mock';
+import { Tag } from 'src/views/CRUD/types';
+
+const mockedProps = {
+ onHide: () => {},
+ refreshData: () => {},
+ addSuccessToast: () => {},
+ addDangerToast: () => {},
+ show: true,
+};
+
+const fetchEditFetchObjects = `glob:*/api/v1/tag/get_objects/?tags=*`;
+
+test('should render', () => {
+ const { container } = render(<TagModal {...mockedProps} />);
+ expect(container).toBeInTheDocument();
+});
+
+test('renders correctly in create mode', () => {
+ const { getByPlaceholderText, getByText } = render(
+ <TagModal {...mockedProps} />,
+ );
+
+ expect(getByPlaceholderText('Name of your tag')).toBeInTheDocument();
+ expect(getByText('Create Tag')).toBeInTheDocument();
+});
+
+test('renders correctly in edit mode', () => {
+ fetchMock.get(fetchEditFetchObjects, [[]]);
+ const editTag: Tag = {
+ id: 1,
+ name: 'Test Tag',
+ description: 'A test tag',
+ type: 'dashboard',
+ changed_on_delta_humanized: '',
+ created_by: {},
+ };
+
+ render(<TagModal {...mockedProps} editTag={editTag} />);
+ expect(screen.getByPlaceholderText(/name of your tag/i)).toHaveValue(
+ editTag.name,
+ );
+});
diff --git a/superset-frontend/src/features/tags/TagModal.tsx b/superset-frontend/src/features/tags/TagModal.tsx
new file mode 100644
index 0000000000..bbe32102c6
--- /dev/null
+++ b/superset-frontend/src/features/tags/TagModal.tsx
@@ -0,0 +1,321 @@
+/**
+ * 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, { ChangeEvent, useState, useEffect } from 'react';
+import rison from 'rison';
+import Modal from 'src/components/Modal';
+import AsyncSelect from 'src/components/Select/AsyncSelect';
+import { FormLabel } from 'src/components/Form';
+import { t, SupersetClient } from '@superset-ui/core';
+import { Input } from 'antd';
+import { Divider } from 'src/components';
+import Button from 'src/components/Button';
+import { Tag } from 'src/views/CRUD/types';
+import { fetchObjects } from 'src/features/tags/tags';
+
+interface TaggableResourceOption {
+ label: string;
+ value: number;
+ key: number;
+}
+
+export enum TaggableResources {
+ Chart = 'chart',
+ Dashboard = 'dashboard',
+ SavedQuery = 'query',
+}
+
+interface TagModalProps {
+ onHide: () => void;
+ refreshData: () => void;
+ addSuccessToast: (msg: string) => void;
+ addDangerToast: (msg: string) => void;
+ show: boolean;
+ editTag?: Tag | null;
+}
+
+const TagModal: React.FC<TagModalProps> = ({
+ show,
+ onHide,
+ editTag,
+ refreshData,
+ addSuccessToast,
+ addDangerToast,
+}) => {
+ const [dashboardsToTag, setDashboardsToTag] = useState<
+ TaggableResourceOption[]
+ >([]);
+ const [chartsToTag, setChartsToTag] = useState<TaggableResourceOption[]>([]);
+ const [savedQueriesToTag, setSavedQueriesToTag] = useState<
+ TaggableResourceOption[]
+ >([]);
+
+ const [tagName, setTagName] = useState<string>('');
+ const [description, setDescription] = useState<string>('');
+
+ const isEditMode = !!editTag;
+ const modalTitle = isEditMode ? 'Edit Tag' : 'Create Tag';
+
+ const clearResources = () => {
+ setDashboardsToTag([]);
+ setChartsToTag([]);
+ setSavedQueriesToTag([]);
+ };
+
+ useEffect(() => {
+ const resourceMap: { [key: string]: TaggableResourceOption[] } = {
+ [TaggableResources.Dashboard]: [],
+ [TaggableResources.Chart]: [],
+ [TaggableResources.SavedQuery]: [],
+ };
+
+ const updateResourceOptions = ({ id, name, type }: Tag) => {
+ const resourceOptions = resourceMap[type];
+ if (resourceOptions) {
+ resourceOptions.push({
+ value: id,
+ label: name,
+ key: id,
+ });
+ }
+ };
+ clearResources();
+ if (isEditMode) {
+ fetchObjects(
+ { tags: editTag.name, types: null },
+ (data: Tag[]) => {
+ data.forEach(updateResourceOptions);
+ setDashboardsToTag(resourceMap[TaggableResources.Dashboard]);
+ setChartsToTag(resourceMap[TaggableResources.Chart]);
+ setSavedQueriesToTag(resourceMap[TaggableResources.SavedQuery]);
+ },
+ (error: Response) => {
+ addDangerToast('Error Fetching Tagged Objects');
+ },
+ );
+ setTagName(editTag.name);
+ setDescription(editTag.description);
+ }
+ }, [editTag]);
+
+ const loadData = async (
+ search: string,
+ page: number,
+ pageSize: number,
+ columns: string[],
+ filterColumn: string,
+ orderColumn: string,
+ endpoint: string,
+ ) => {
+ const queryParams = rison.encode({
+ columns,
+ filters: [
+ {
+ col: filterColumn,
+ opr: 'ct',
+ value: search,
+ },
+ ],
+ page,
+ order_column: orderColumn,
+ });
+
+ const { json } = await SupersetClient.get({
+ endpoint: `/api/v1/${endpoint}/?q=${queryParams}`,
+ });
+ const { result, count } = json;
+
+ return {
+ data: result.map((item: { id: number }) => ({
+ value: item.id,
+ label: item[filterColumn],
+ })),
+ totalCount: count,
+ };
+ };
+
+ const loadCharts = async (search: string, page: number, pageSize: number) =>
+ loadData(
+ search,
+ page,
+ pageSize,
+ ['id', 'slice_name'],
+ 'slice_name',
+ 'slice_name',
+ 'chart',
+ );
+
+ const loadDashboards = async (
+ search: string,
+ page: number,
+ pageSize: number,
+ ) =>
+ loadData(
+ search,
+ page,
+ pageSize,
+ ['id', 'dashboard_title'],
+ 'dashboard_title',
+ 'dashboard_title',
+ 'dashboard',
+ );
+
+ const loadQueries = async (search: string, page: number, pageSize: number) =>
+ loadData(
+ search,
+ page,
+ pageSize,
+ ['id', 'label'],
+ 'label',
+ 'label',
+ 'saved_query',
+ );
+
+ const handleOptionChange = (resource: TaggableResources, data: any) => {
+ if (resource === TaggableResources.Dashboard) setDashboardsToTag(data);
+ else if (resource === TaggableResources.Chart) setChartsToTag(data);
+ else if (resource === TaggableResources.SavedQuery)
+ setSavedQueriesToTag(data);
+ };
+
+ const handleTagNameChange = (ev: ChangeEvent<HTMLInputElement>) =>
+ setTagName(ev.target.value);
+ const handleDescriptionChange = (ev: ChangeEvent<HTMLInputElement>) =>
+ setDescription(ev.target.value);
+
+ const onSave = () => {
+ const dashboards = dashboardsToTag.map(dash => ['dashboard', dash.value]);
+ const charts = chartsToTag.map(chart => ['chart', chart.value]);
+ const savedQueries = savedQueriesToTag.map(q => ['query', q.value]);
+
+ if (isEditMode) {
+ SupersetClient.put({
+ endpoint: `/api/v1/tag/${editTag.id}`,
+ jsonPayload: {
+ description,
+ name: tagName,
+ objects_to_tag: [...dashboards, ...charts, ...savedQueries],
+ },
+ }).then(({ json = {} }) => {
+ refreshData();
+ addSuccessToast(t('Tag updated'));
+ });
+ } else {
+ SupersetClient.post({
+ endpoint: `/api/v1/tag/`,
+ jsonPayload: {
+ description,
+ name: tagName,
+ objects_to_tag: [...dashboards, ...charts, ...savedQueries],
+ },
+ }).then(({ json = {} }) => {
+ refreshData();
+ addSuccessToast(t('Tag created'));
+ });
+ }
+ onHide();
+ };
+
+ return (
+ <Modal
+ title={modalTitle}
+ onHide={() => {
+ setTagName('');
+ setDescription('');
+ setDashboardsToTag([]);
+ setChartsToTag([]);
+ setSavedQueriesToTag([]);
+ onHide();
+ }}
+ show={show}
+ footer={
+ <div>
+ <Button
+ data-test="modal-save-dashboard-button"
+ buttonStyle="secondary"
+ onClick={onHide}
+ >
+ {t('Cancel')}
+ </Button>
+ <Button
+ data-test="modal-save-dashboard-button"
+ buttonStyle="primary"
+ onClick={onSave}
+ >
+ {t('Save')}
+ </Button>
+ </div>
+ }
+ >
+ <>
+ <FormLabel>{t('Tag Name')}</FormLabel>
+ <Input
+ onChange={handleTagNameChange}
+ placeholder={t('Name of your tag')}
+ value={tagName}
+ />
+ <FormLabel>{t('Description')}</FormLabel>
+ <Input
+ onChange={handleDescriptionChange}
+ placeholder={t('Add description of your tag')}
+ value={description}
+ />
+ <Divider />
+ <AsyncSelect
+ ariaLabel={t('Select Dashboards')}
+ mode="multiple"
+ name="dashboards"
+ // @ts-ignore
+ value={dashboardsToTag}
+ options={loadDashboards}
+ onChange={value =>
+ handleOptionChange(TaggableResources.Dashboard, value)
+ }
+ header={<FormLabel>{t('Dashboards')}</FormLabel>}
+ allowClear
+ />
+ <AsyncSelect
+ ariaLabel={t('Select Charts')}
+ mode="multiple"
+ name="charts"
+ // @ts-ignore
+ value={chartsToTag}
+ options={loadCharts}
+ onChange={value => handleOptionChange(TaggableResources.Chart, value)}
+ header={<FormLabel>{t('Charts')}</FormLabel>}
+ allowClear
+ />
+ <AsyncSelect
+ ariaLabel={t('Select Saved Queries')}
+ mode="multiple"
+ name="savedQueries"
+ // @ts-ignore
+ value={savedQueriesToTag}
+ options={loadQueries}
+ onChange={value =>
+ handleOptionChange(TaggableResources.SavedQuery, value)
+ }
+ header={<FormLabel>{t('Saved Queries')}</FormLabel>}
+ allowClear
+ />
+ </>
+ </Modal>
+ );
+};
+
+export default TagModal;
diff --git a/superset-frontend/src/features/tags/tags.ts b/superset-frontend/src/features/tags/tags.ts
index ff0b8f3a33..97b5b094b3 100644
--- a/superset-frontend/src/features/tags/tags.ts
+++ b/superset-frontend/src/features/tags/tags.ts
@@ -55,6 +55,16 @@ export function fetchAllTags(
.catch(response => error(response));
}
+export function fetchSingleTag(
+ name: string,
+ 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,
diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx
index 03a2b1da9c..fa623f03c5 100644
--- a/superset-frontend/src/pages/Tags/index.tsx
+++ b/superset-frontend/src/pages/Tags/index.tsx
@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
+import React, { useMemo, useCallback, useState } from 'react';
import { isFeatureEnabled, FeatureFlag, t } from '@superset-ui/core';
-import React, { useMemo, useCallback } from 'react';
import {
createFetchRelated,
createErrorHandler,
@@ -41,23 +41,9 @@ import { deleteTags } from 'src/features/tags/tags';
import { Tag as AntdTag } from 'antd';
import { Tag } from 'src/views/CRUD/types';
import TagCard from 'src/features/tags/TagCard';
+import TagModal from 'src/features/tags/TagModal';
import FaveStar from 'src/components/FaveStar';
-const emptyState = {
- title: t('No Tags created'),
- image: 'dashboard.svg',
- description:
- 'Create a new tag and assign it to existing entities like charts or dashboards',
- buttonAction: () => {},
- // todo(hughhh): Add this back once Tag modal is functional
- // buttonText: (
- // <>
- // <i className="fa fa-plus" data-test="add-rule-empty" />{' '}
- // {'Create a new Tag'}{' '}
- // </>
- // ),
-};
-
const PAGE_SIZE = 25;
interface TagListProps {
@@ -90,6 +76,8 @@ function TagList(props: TagListProps) {
refreshData,
} = useListViewResource<Tag>('tag', t('tag'), addDangerToast);
+ const [showTagModal, setShowTagModal] = useState<boolean>(false);
+ const [tagToEdit, setTagToEdit] = useState<Tag | null>(null);
const tagIds = useMemo(() => tags.map(c => c.id), [tags]);
const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
'tag',
@@ -101,6 +89,7 @@ function TagList(props: TagListProps) {
const userKey = dangerouslyGetItemDoNotUse(userId?.toString(), null);
const canDelete = hasPerm('can_write');
+ const canEdit = hasPerm('can_write');
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
@@ -114,6 +103,25 @@ function TagList(props: TagListProps) {
refreshData();
}
+ const handleTagEdit = (tag: Tag) => {
+ setShowTagModal(true);
+ setTagToEdit(tag);
+ };
+
+ const emptyState = {
+ title: t('No Tags created'),
+ image: 'dashboard.svg',
+ description:
+ 'Create a new tag and assign it to existing entities like charts or dashboards',
+ buttonAction: () => setShowTagModal(true),
+ buttonText: (
+ <>
+ <i className="fa fa-plus" data-test="add-rule-empty" />{' '}
+ {'Create a new Tag'}{' '}
+ </>
+ ),
+ };
+
const columns = useMemo(
() => [
{
@@ -175,6 +183,7 @@ function TagList(props: TagListProps) {
Cell: ({ row: { original } }: any) => {
const handleDelete = () =>
handleTagsDelete([original], addSuccessToast, addDangerToast);
+ const handleEdit = () => handleTagEdit(original);
return (
<Actions className="actions">
{canDelete && (
@@ -206,6 +215,22 @@ function TagList(props: TagListProps) {
)}
</ConfirmStatusChange>
)}
+ {canEdit && (
+ <Tooltip
+ id="edit-action-tooltip"
+ title={t('Edit')}
+ placement="bottom"
+ >
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={handleEdit}
+ >
+ <Icons.EditAlt data-test="edit-alt" />
+ </span>
+ </Tooltip>
+ )}
</Actions>
);
},
@@ -303,6 +328,7 @@ function TagList(props: TagListProps) {
);
const subMenuButtons: SubMenuProps['buttons'] = [];
+
if (canDelete) {
subMenuButtons.push({
name: t('Bulk select'),
@@ -312,11 +338,30 @@ function TagList(props: TagListProps) {
});
}
+ // render new 'New Tag' btn
+ subMenuButtons.push({
+ name: t('New Tag'),
+ buttonStyle: 'primary',
+ 'data-test': 'bulk-select',
+ onClick: () => setShowTagModal(true),
+ });
+
const handleBulkDelete = (tagsToDelete: Tag[]) =>
handleTagsDelete(tagsToDelete, addSuccessToast, addDangerToast);
return (
<>
+ <TagModal
+ show={showTagModal}
+ onHide={() => {
+ setShowTagModal(false);
+ setTagToEdit(null);
+ }}
+ editTag={tagToEdit}
+ refreshData={refreshData}
+ addSuccessToast={addSuccessToast}
+ addDangerToast={addDangerToast}
+ />
<SubMenu name={t('Tags')} buttons={subMenuButtons} />
<ConfirmStatusChange
title={t('Please confirm')}
diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts
index 20800b71ce..0b37997f16 100644
--- a/superset-frontend/src/views/CRUD/types.ts
+++ b/superset-frontend/src/views/CRUD/types.ts
@@ -141,6 +141,8 @@ export interface Tag {
name: string;
id: number;
created_by: object;
+ description: string;
+ type: string;
}
export type DatabaseObject = Partial<Database> &
diff --git a/superset/daos/tag.py b/superset/daos/tag.py
index 8e4437f49d..1a0843cc0e 100644
--- a/superset/daos/tag.py
+++ b/superset/daos/tag.py
@@ -29,6 +29,7 @@ from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import SavedQuery
from superset.tags.commands.exceptions import TagNotFoundError
+from superset.tags.commands.utils import to_object_type
from superset.tags.models import (
get_tag,
ObjectTypes,
@@ -363,3 +364,46 @@ class TagDAO(BaseDAO[Tag]):
)
.all()
]
+
+ @staticmethod
+ def create_tag_relationship(
+ objects_to_tag: list[tuple[ObjectTypes, int]], tag: Tag
+ ) -> None:
+ """
+ Creates a tag relationship between the given objects and the specified tag.
+ This function iterates over a list of objects, each specified by a type
+ and an id, and creates a TaggedObject for each one, associating it with
+ the provided tag. All created TaggedObjects are collected in a list.
+ Args:
+ objects_to_tag (List[Tuple[ObjectTypes, int]]): A list of tuples, each
+ containing an ObjectType and an id, representing the objects to be tagged.
+
+ tag (Tag): The tag to be associated with the specified objects.
+ Returns:
+ None.
+ """
+ tagged_objects = []
+ if not tag:
+ raise TagNotFoundError()
+
+ current_tagged_objects = {
+ (obj.object_type, obj.object_id) for obj in tag.objects
+ }
+ updated_tagged_objects = {
+ (to_object_type(obj[0]), obj[1]) for obj in objects_to_tag
+ }
+ tagged_objects_to_delete = current_tagged_objects - updated_tagged_objects
+
+ for object_type, object_id in updated_tagged_objects:
+ # create rows for new objects, and skip tags that already exist
+ if (object_type, object_id) not in current_tagged_objects:
+ tagged_objects.append(
+ TaggedObject(object_id=object_id, object_type=object_type, tag=tag)
+ )
+
+ for object_type, object_id in tagged_objects_to_delete:
+ # delete objects that were removed
+ TagDAO.delete_tagged_object(object_type, object_id, tag.name) # type: ignore
+
+ db.session.add_all(tagged_objects)
+ db.session.commit()
diff --git a/superset/tags/api.py b/superset/tags/api.py
index a12461e8e4..a760b33921 100644
--- a/superset/tags/api.py
+++ b/superset/tags/api.py
@@ -20,12 +20,16 @@ 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 marshmallow import ValidationError
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.daos.tag import TagDAO
from superset.exceptions import MissingUserContextException
from superset.extensions import event_logger
-from superset.tags.commands.create import CreateCustomTagCommand
+from superset.tags.commands.create import (
+ CreateCustomTagCommand,
+ CreateCustomTagWithRelationshipsCommand,
+)
from superset.tags.commands.delete import DeleteTaggedObjectCommand, DeleteTagsCommand
from superset.tags.commands.exceptions import (
TagCreateFailedError,
@@ -34,7 +38,9 @@ from superset.tags.commands.exceptions import (
TaggedObjectNotFoundError,
TagInvalidError,
TagNotFoundError,
+ TagUpdateFailedError,
)
+from superset.tags.commands.update import UpdateTagCommand
from superset.tags.models import ObjectTypes, Tag
from superset.tags.schemas import (
delete_tags_schema,
@@ -42,6 +48,7 @@ from superset.tags.schemas import (
TaggedObjectEntityResponseSchema,
TagGetResponseSchema,
TagPostSchema,
+ TagPutSchema,
)
from superset.views.base_api import (
BaseSupersetModelRestApi,
@@ -77,6 +84,7 @@ class TagRestApi(BaseSupersetModelRestApi):
"id",
"name",
"type",
+ "description",
"changed_by.first_name",
"changed_by.last_name",
"changed_on_delta_humanized",
@@ -90,6 +98,7 @@ class TagRestApi(BaseSupersetModelRestApi):
"id",
"name",
"type",
+ "description",
"changed_by.first_name",
"changed_by.last_name",
"changed_on_delta_humanized",
@@ -108,6 +117,7 @@ class TagRestApi(BaseSupersetModelRestApi):
allowed_rel_fields = {"created_by"}
add_model_schema = TagPostSchema()
+ edit_model_schema = TagPutSchema()
tag_get_response_schema = TagGetResponseSchema()
object_entity_response_schema = TaggedObjectEntityResponseSchema()
@@ -131,6 +141,131 @@ class TagRestApi(BaseSupersetModelRestApi):
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__}.post",
+ log_to_statsd=False,
+ )
+ def post(self) -> Response:
+ """Creates a new Tags and tag items
+ ---
+ post:
+ description: >-
+ Create a new Tag
+ requestBody:
+ description: Tag schema
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+ responses:
+ 201:
+ description: Tag added
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: number
+ result:
+ $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+ 400:
+ $ref: '#/components/responses/400'
+ 401:
+ $ref: '#/components/responses/401'
+ 422:
+ $ref: '#/components/responses/422'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ try:
+ item = self.add_model_schema.load(request.json)
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
+ try:
+ CreateCustomTagWithRelationshipsCommand(item).run()
+ return self.response(201)
+ 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_500(message=str(ex))
+
+ @expose("/<pk>", methods=("PUT",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
+ log_to_statsd=False,
+ )
+ def put(self, pk: int) -> Response:
+ """Changes a Tag
+ ---
+ put:
+ description: >-
+ Changes a Tag.
+ parameters:
+ - in: path
+ schema:
+ type: integer
+ name: pk
+ requestBody:
+ description: Chart schema
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+ responses:
+ 200:
+ description: Tag changed
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: number
+ result:
+ $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+ 400:
+ $ref: '#/components/responses/400'
+ 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:
+ item = self.edit_model_schema.load(request.json)
+ # This validates custom Schema with custom validations
+ except ValidationError as error:
+ return self.response_400(message=error.messages)
+ item = request.json
+ try:
+ changed_model = UpdateTagCommand(pk, item).run()
+ response = self.response(200, id=changed_model.id, result=item)
+ except TagUpdateFailedError as ex:
+ response = self.response_422(message=str(ex))
+
+ return response
+
@expose("/<int:object_type>/<int:object_id>/", methods=("POST",))
@protect()
@safe
@@ -201,7 +336,7 @@ class TagRestApi(BaseSupersetModelRestApi):
str(ex),
exc_info=True,
)
- return self.response_422(message=str(ex))
+ return self.response_500(message=str(ex))
@expose("/<int:object_type>/<int:object_id>/<tag>/", methods=("DELETE",))
@protect()
@@ -387,7 +522,7 @@ class TagRestApi(BaseSupersetModelRestApi):
str(ex),
exc_info=True,
)
- return self.response_422(message=str(ex))
+ return self.response_500(message=str(ex))
@expose("/favorite_status/", methods=("GET",))
@protect()
diff --git a/superset/tags/commands/create.py b/superset/tags/commands/create.py
index 7e9f040015..5c30b548bd 100644
--- a/superset/tags/commands/create.py
+++ b/superset/tags/commands/create.py
@@ -15,13 +15,15 @@
# specific language governing permissions and limitations
# under the License.
import logging
+from typing import Any
+from superset import db
from superset.commands.base import BaseCommand, CreateMixin
from superset.daos.exceptions import DAOCreateFailedError
from superset.daos.tag import TagDAO
from superset.tags.commands.exceptions import TagCreateFailedError, TagInvalidError
from superset.tags.commands.utils import to_object_type
-from superset.tags.models import ObjectTypes
+from superset.tags.models import ObjectTypes, TagTypes
logger = logging.getLogger(__name__)
@@ -60,3 +62,45 @@ class CreateCustomTagCommand(CreateMixin, BaseCommand):
)
if exceptions:
raise TagInvalidError(exceptions=exceptions)
+
+
+class CreateCustomTagWithRelationshipsCommand(CreateMixin, BaseCommand):
+ def __init__(self, data: dict[str, Any]):
+ self._tag = data["name"]
+ self._objects_to_tag = data.get("objects_to_tag")
+ self._description = data.get("description")
+
+ def run(self) -> None:
+ self.validate()
+ try:
+ tag = TagDAO.get_by_name(self._tag.strip(), TagTypes.custom)
+ if self._objects_to_tag:
+ TagDAO.create_tag_relationship(
+ objects_to_tag=self._objects_to_tag, tag=tag
+ )
+
+ if self._description:
+ tag.description = self._description
+ db.session.commit()
+
+ except DAOCreateFailedError as ex:
+ logger.exception(ex.exception)
+ raise TagCreateFailedError() from ex
+
+ def validate(self) -> None:
+ exceptions = []
+ # Validate object_id
+ if self._objects_to_tag:
+ if any(obj_id == 0 for obj_type, obj_id in self._objects_to_tag):
+ exceptions.append(TagInvalidError())
+
+ # Validate object type
+ for obj_type, obj_id in self._objects_to_tag:
+ object_type = to_object_type(obj_type)
+ if not object_type:
+ exceptions.append(
+ TagInvalidError(f"invalid object type {object_type}")
+ )
+
+ if exceptions:
+ raise TagInvalidError(exceptions=exceptions)
diff --git a/superset/tags/commands/exceptions.py b/superset/tags/commands/exceptions.py
index 9847c949bf..6778c8e221 100644
--- a/superset/tags/commands/exceptions.py
+++ b/superset/tags/commands/exceptions.py
@@ -23,6 +23,8 @@ from superset.commands.exceptions import (
CommandInvalidError,
CreateFailedError,
DeleteFailedError,
+ ObjectNotFoundError,
+ UpdateFailedError,
)
@@ -34,6 +36,10 @@ class TagCreateFailedError(CreateFailedError):
message = _("Tag could not be created.")
+class TagUpdateFailedError(UpdateFailedError):
+ message = _("Tag could not be updated.")
+
+
class TagDeleteFailedError(DeleteFailedError):
message = _("Tag could not be deleted.")
@@ -42,7 +48,7 @@ class TaggedObjectDeleteFailedError(DeleteFailedError):
message = _("Tagged Object could not be deleted.")
-class TagNotFoundError(CommandException):
+class TagNotFoundError(ObjectNotFoundError):
def __init__(self, tag_name: Optional[str] = None) -> None:
message = "Tag not found."
if tag_name:
diff --git a/superset/tags/commands/update.py b/superset/tags/commands/update.py
new file mode 100644
index 0000000000..a13e4e8e7b
--- /dev/null
+++ b/superset/tags/commands/update.py
@@ -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 logging
+from typing import Any, Optional
+
+from flask_appbuilder.models.sqla import Model
+
+from superset import db
+from superset.commands.base import BaseCommand, UpdateMixin
+from superset.daos.tag import TagDAO
+from superset.tags.commands.exceptions import TagInvalidError, TagNotFoundError
+from superset.tags.commands.utils import to_object_type
+from superset.tags.models import Tag
+
+logger = logging.getLogger(__name__)
+
+
+class UpdateTagCommand(UpdateMixin, BaseCommand):
+ def __init__(self, model_id: int, data: dict[str, Any]):
+ self._model_id = model_id
+ self._properties = data.copy()
+ self._model: Optional[Tag] = None
+
+ def run(self) -> Model:
+ self.validate()
+ if self._model:
+ if self._properties.get("objects_to_tag"):
+ # todo(hugh): can this manage duplication
+ TagDAO.create_tag_relationship(
+ objects_to_tag=self._properties["objects_to_tag"],
+ tag=self._model,
+ )
+ if description := self._properties.get("description"):
+ self._model.description = description
+ if tag_name := self._properties.get("name"):
+ self._model.name = tag_name
+
+ db.session.add(self._model)
+ db.session.commit()
+
+ return self._model
+
+ def validate(self) -> None:
+ exceptions = []
+ # Validate/populate model exists
+ self._model = TagDAO.find_by_id(self._model_id)
+ if not self._model:
+ raise TagNotFoundError()
+
+ # Validate object_id
+ if objects_to_tag := self._properties.get("objects_to_tag"):
+ if any(obj_id == 0 for obj_type, obj_id in objects_to_tag):
+ exceptions.append(TagInvalidError(" invalid object_id"))
+
+ # Validate object type
+ for obj_type, obj_id in objects_to_tag:
+ object_type = to_object_type(obj_type)
+ if not object_type:
+ exceptions.append(
+ TagInvalidError(f"invalid object type {object_type}")
+ )
+
+ if exceptions:
+ raise TagInvalidError(exceptions=exceptions)
diff --git a/superset/tags/schemas.py b/superset/tags/schemas.py
index f519901a8b..89f15d4bf8 100644
--- a/superset/tags/schemas.py
+++ b/superset/tags/schemas.py
@@ -55,4 +55,18 @@ class TagGetResponseSchema(Schema):
class TagPostSchema(Schema):
- tags = fields.List(fields.String())
+ name = fields.String()
+ description = fields.String(required=False)
+ # resource id's to tag with tag
+ objects_to_tag = fields.List(
+ fields.Tuple((fields.String(), fields.Int())), required=False
+ )
+
+
+class TagPutSchema(Schema):
+ name = fields.String()
+ description = fields.String(required=False)
+ # resource id's to tag with tag
+ objects_to_tag = fields.List(
+ fields.Tuple((fields.String(), fields.Int())), required=False
+ )
diff --git a/tests/integration_tests/tags/api_tests.py b/tests/integration_tests/tags/api_tests.py
index d1231db97e..e0f4de87eb 100644
--- a/tests/integration_tests/tags/api_tests.py
+++ b/tests/integration_tests/tags/api_tests.py
@@ -53,6 +53,7 @@ TAGS_LIST_COLUMNS = [
"id",
"name",
"type",
+ "description",
"changed_by.first_name",
"changed_by.last_name",
"changed_on_delta_humanized",
@@ -457,3 +458,46 @@ class TestTagApi(SupersetTestCase):
rv = self.client.delete(uri, follow_redirects=True)
self.assertEqual(rv.status_code, 422)
+
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ def test_post_tag(self):
+ self.login(username="admin")
+ uri = f"api/v1/tag/"
+ dashboard = (
+ db.session.query(Dashboard)
+ .filter(Dashboard.dashboard_title == "World Bank's Data")
+ .first()
+ )
+ rv = self.client.post(
+ uri,
+ json={"name": "my_tag", "objects_to_tag": [["dashboard", dashboard.id]]},
+ )
+
+ self.assertEqual(rv.status_code, 201)
+ user_id = self.get_user(username="admin").get_id()
+ tag = (
+ db.session.query(Tag)
+ .filter(Tag.name == "my_tag", Tag.type == TagTypes.custom)
+ .one_or_none()
+ )
+ assert tag is not None
+
+ @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+ @pytest.mark.usefixtures("create_tags")
+ def test_put_tag(self):
+ self.login(username="admin")
+
+ tag_to_update = db.session.query(Tag).first()
+ uri = f"api/v1/tag/{tag_to_update.id}"
+ rv = self.client.put(
+ uri, json={"name": "new_name", "description": "new description"}
+ )
+
+ self.assertEqual(rv.status_code, 200)
+
+ tag = (
+ db.session.query(Tag)
+ .filter(Tag.name == "new_name", Tag.description == "new description")
+ .one_or_none()
+ )
+ assert tag is not None
diff --git a/tests/unit_tests/dao/tag_test.py b/tests/unit_tests/dao/tag_test.py
index 3f5666d2b5..476c51e45d 100644
--- a/tests/unit_tests/dao/tag_test.py
+++ b/tests/unit_tests/dao/tag_test.py
@@ -144,3 +144,31 @@ def test_user_favorite_tag_exc_raise(mocker):
mock_session.commit.side_effect = Exception("DB Error")
with pytest.raises(Exception):
TagDAO.remove_user_favorite_tag(1)
+
+
+def test_create_tag_relationship(mocker):
+ from superset.daos.tag import TagDAO
+ from superset.tags.models import ( # Assuming these are defined in the same module
+ ObjectTypes,
+ TaggedObject,
+ )
+
+ mock_session = mocker.patch("superset.daos.tag.db.session")
+
+ # Define a list of objects to tag
+ objects_to_tag = [
+ (ObjectTypes.query, 1),
+ (ObjectTypes.chart, 2),
+ (ObjectTypes.dashboard, 3),
+ ]
+
+ # Call the function
+ tag = TagDAO.get_by_name("test_tag")
+ TagDAO.create_tag_relationship(objects_to_tag, tag)
+
+ # Verify that the correct number of TaggedObjects are added to the session
+ assert mock_session.add_all.call_count == 1
+ assert len(mock_session.add_all.call_args[0][0]) == len(objects_to_tag)
+
+ # Verify that commit is called
+ mock_session.commit.assert_called_once()
diff --git a/tests/unit_tests/tags/__init__.py b/tests/unit_tests/tags/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/unit_tests/tags/commands/create_test.py b/tests/unit_tests/tags/commands/create_test.py
new file mode 100644
index 0000000000..a188625b40
--- /dev/null
+++ b/tests/unit_tests/tags/commands/create_test.py
@@ -0,0 +1,110 @@
+import pytest
+from sqlalchemy.orm.session import Session
+
+from superset.utils.core import DatasourceType
+
+
+@pytest.fixture
+def session_with_data(session: Session):
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+ from superset.models.core import Database
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.models.sql_lab import Query, SavedQuery
+
+ engine = session.get_bind()
+ SqlaTable.metadata.create_all(engine) # pylint: disable=no-member
+
+ slice_obj = Slice(
+ id=1,
+ datasource_id=1,
+ datasource_type=DatasourceType.TABLE,
+ datasource_name="tmp_perm_table",
+ slice_name="slice_name",
+ )
+
+ db = Database(database_name="my_database", sqlalchemy_uri="postgresql://")
+
+ columns = [
+ TableColumn(column_name="a", type="INTEGER"),
+ ]
+
+ saved_query = SavedQuery(label="test_query", database=db, sql="select * from foo")
+
+ dashboard_obj = Dashboard(
+ id=100,
+ dashboard_title="test_dashboard",
+ slug="test_slug",
+ slices=[],
+ published=True,
+ )
+
+ session.add(slice_obj)
+ session.add(db)
+ session.add(saved_query)
+ session.add(dashboard_obj)
+ session.commit()
+ yield session
+
+
+def test_create_command_success(session_with_data: Session):
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.daos.tag import TagDAO
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.models.sql_lab import Query, SavedQuery
+ from superset.tags.commands.create import CreateCustomTagWithRelationshipsCommand
+ from superset.tags.models import ObjectTypes, TaggedObject
+
+ # Define a list of objects to tag
+ query = session_with_data.query(SavedQuery).first()
+ chart = session_with_data.query(Slice).first()
+ dashboard = session_with_data.query(Dashboard).first()
+
+ objects_to_tag = [
+ (ObjectTypes.query, query.id),
+ (ObjectTypes.chart, chart.id),
+ (ObjectTypes.dashboard, dashboard.id),
+ ]
+
+ CreateCustomTagWithRelationshipsCommand(
+ data={"name": "test_tag", "objects_to_tag": objects_to_tag}
+ ).run()
+
+ assert len(session_with_data.query(TaggedObject).all()) == len(objects_to_tag)
+ for object_type, object_id in objects_to_tag:
+ assert (
+ session_with_data.query(TaggedObject)
+ .filter(
+ TaggedObject.object_type == object_type,
+ TaggedObject.object_id == object_id,
+ )
+ .one_or_none()
+ is not None
+ )
+
+
+def test_create_command_failed_validate(session_with_data: Session):
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.daos.tag import TagDAO
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.models.sql_lab import Query, SavedQuery
+ from superset.tags.commands.create import CreateCustomTagWithRelationshipsCommand
+ from superset.tags.commands.exceptions import TagInvalidError
+ from superset.tags.models import ObjectTypes, TaggedObject
+
+ query = session_with_data.query(SavedQuery).first()
+ chart = session_with_data.query(Slice).first()
+ dashboard = session_with_data.query(Dashboard).first()
+
+ objects_to_tag = [
+ (ObjectTypes.query, query.id),
+ (ObjectTypes.chart, chart.id),
+ (ObjectTypes.dashboard, 0),
+ ]
+
+ with pytest.raises(TagInvalidError):
+ CreateCustomTagWithRelationshipsCommand(
+ data={"name": "test_tag", "objects_to_tag": objects_to_tag}
+ ).run()
diff --git a/tests/unit_tests/tags/commands/update_test.py b/tests/unit_tests/tags/commands/update_test.py
new file mode 100644
index 0000000000..2c2454547e
--- /dev/null
+++ b/tests/unit_tests/tags/commands/update_test.py
@@ -0,0 +1,160 @@
+import pytest
+from sqlalchemy.orm.session import Session
+
+from superset.utils.core import DatasourceType
+
+
+@pytest.fixture
+def session_with_data(session: Session):
+ from superset.connectors.sqla.models import SqlaTable, TableColumn
+ from superset.models.core import Database
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.models.sql_lab import Query, SavedQuery
+ from superset.tags.models import Tag
+
+ engine = session.get_bind()
+ Tag.metadata.create_all(engine) # pylint: disable=no-member
+
+ slice_obj = Slice(
+ id=1,
+ datasource_id=1,
+ datasource_type=DatasourceType.TABLE,
+ datasource_name="tmp_perm_table",
+ slice_name="slice_name",
+ )
+
+ db = Database(database_name="my_database", sqlalchemy_uri="postgresql://")
+
+ columns = [
+ TableColumn(column_name="a", type="INTEGER"),
+ ]
+
+ sqla_table = SqlaTable(
+ table_name="my_sqla_table",
+ columns=columns,
+ metrics=[],
+ database=db,
+ )
+
+ dashboard_obj = Dashboard(
+ id=100,
+ dashboard_title="test_dashboard",
+ slug="test_slug",
+ slices=[],
+ published=True,
+ )
+
+ saved_query = SavedQuery(label="test_query", database=db, sql="select * from foo")
+
+ tag = Tag(name="test_name", description="test_description")
+
+ session.add(slice_obj)
+ session.add(dashboard_obj)
+ session.add(tag)
+ session.commit()
+ yield session
+
+
+def test_update_command_success(session_with_data: Session):
+ from superset.daos.tag import TagDAO
+ from superset.models.dashboard import Dashboard
+ from superset.tags.commands.update import UpdateTagCommand
+ from superset.tags.models import ObjectTypes, TaggedObject
+
+ dashboard = session_with_data.query(Dashboard).first()
+
+ objects_to_tag = [
+ (ObjectTypes.dashboard, dashboard.id),
+ ]
+
+ tag_to_update = TagDAO.find_by_name("test_name")
+ changed_model = UpdateTagCommand(
+ tag_to_update.id,
+ {
+ "name": "new_name",
+ "description": "new_description",
+ "objects_to_tag": objects_to_tag,
+ },
+ ).run()
+
+ updated_tag = TagDAO.find_by_name("new_name")
+ assert updated_tag is not None
+ assert updated_tag.description == "new_description"
+ assert len(session_with_data.query(TaggedObject).all()) == len(objects_to_tag)
+
+
+def test_update_command_success_duplicates(session_with_data: Session):
+ from superset.daos.tag import TagDAO
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.tags.commands.create import CreateCustomTagWithRelationshipsCommand
+ from superset.tags.commands.update import UpdateTagCommand
+ from superset.tags.models import ObjectTypes, TaggedObject
+
+ dashboard = session_with_data.query(Dashboard).first()
+ chart = session_with_data.query(Slice).first()
+
+ objects_to_tag = [
+ (ObjectTypes.dashboard, dashboard.id),
+ ]
+
+ CreateCustomTagWithRelationshipsCommand(
+ data={"name": "test_tag", "objects_to_tag": objects_to_tag}
+ ).run()
+
+ tag_to_update = TagDAO.find_by_name("test_tag")
+
+ objects_to_tag = [
+ (ObjectTypes.chart, chart.id),
+ ]
+ changed_model = UpdateTagCommand(
+ tag_to_update.id,
+ {
+ "name": "new_name",
+ "description": "new_description",
+ "objects_to_tag": objects_to_tag,
+ },
+ ).run()
+
+ updated_tag = TagDAO.find_by_name("new_name")
+ assert updated_tag is not None
+ assert updated_tag.description == "new_description"
+ assert len(session_with_data.query(TaggedObject).all()) == len(objects_to_tag)
+ assert changed_model.objects[0].object_id == chart.id
+
+
+def test_update_command_failed_validation(session_with_data: Session):
+ from superset.daos.tag import TagDAO
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.tags.commands.create import CreateCustomTagWithRelationshipsCommand
+ from superset.tags.commands.exceptions import TagInvalidError
+ from superset.tags.commands.update import UpdateTagCommand
+ from superset.tags.models import ObjectTypes, TaggedObject
+
+ dashboard = session_with_data.query(Dashboard).first()
+ chart = session_with_data.query(Slice).first()
+ objects_to_tag = [
+ (ObjectTypes.chart, chart.id),
+ ]
+
+ CreateCustomTagWithRelationshipsCommand(
+ data={"name": "test_tag", "objects_to_tag": objects_to_tag}
+ ).run()
+
+ tag_to_update = TagDAO.find_by_name("test_tag")
+
+ objects_to_tag = [
+ (0, dashboard.id), # type: ignore
+ ]
+
+ with pytest.raises(TagInvalidError):
+ UpdateTagCommand(
+ tag_to_update.id,
+ {
+ "name": "new_name",
+ "description": "new_description",
+ "objects_to_tag": objects_to_tag,
+ },
+ ).run()