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()