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/07/27 17:17:34 UTC

[superset] branch master updated: feat(Tags): Allow users to favorite Tags on CRUD Listview page (#24701)

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 3b46511439 feat(Tags): Allow users to favorite Tags on CRUD Listview page (#24701)
3b46511439 is described below

commit 3b465114395ff30e2eebe07173236692fb85ab76
Author: Hugh A. Miles II <hu...@gmail.com>
AuthorDate: Thu Jul 27 13:17:26 2023 -0400

    feat(Tags): Allow users to favorite Tags on CRUD Listview page (#24701)
---
 superset-frontend/src/pages/Tags/index.tsx         |  29 +++-
 superset-frontend/src/views/CRUD/hooks.ts          |   7 +-
 superset/daos/tag.py                               | 108 ++++++++++++++-
 superset/exceptions.py                             |   4 +
 ...0-34_e0f6f91c2055_create_user_favorite_table.py |  53 ++++++++
 superset/tags/api.py                               | 151 +++++++++++++++++++++
 superset/tags/models.py                            |  14 +-
 tests/integration_tests/tags/api_tests.py          |  85 ++++++++++++
 tests/unit_tests/dao/tag_test.py                   | 146 ++++++++++++++++++++
 9 files changed, 593 insertions(+), 4 deletions(-)

diff --git a/superset-frontend/src/pages/Tags/index.tsx b/superset-frontend/src/pages/Tags/index.tsx
index 41ab3ea5ae..8087b3f4c6 100644
--- a/superset-frontend/src/pages/Tags/index.tsx
+++ b/superset-frontend/src/pages/Tags/index.tsx
@@ -24,7 +24,7 @@ import {
   createErrorHandler,
   Actions,
 } from 'src/views/CRUD/utils';
-import { useListViewResource } from 'src/views/CRUD/hooks';
+import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
 import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
 import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
 import ListView, {
@@ -42,6 +42,7 @@ 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 FaveStar from 'src/components/FaveStar';
 
 const emptyState = {
   title: t('No Tags created'),
@@ -90,6 +91,13 @@ function TagList(props: TagListProps) {
     refreshData,
   } = useListViewResource<Tag>('tag', t('tag'), addDangerToast);
 
+  const tagIds = useMemo(() => tags.map(c => c.id), [tags]);
+  const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(
+    'tag',
+    tagIds,
+    addDangerToast,
+  );
+
   // TODO: Fix usage of localStorage keying on the user id
   const userKey = dangerouslyGetItemDoNotUse(userId?.toString(), null);
 
@@ -109,6 +117,25 @@ function TagList(props: TagListProps) {
 
   const columns = useMemo(
     () => [
+      {
+        Cell: ({
+          row: {
+            original: { id },
+          },
+        }: any) =>
+          userId && (
+            <FaveStar
+              itemId={id}
+              saveFaveStar={saveFavoriteStatus}
+              isStarred={favoriteStatus[id]}
+            />
+          ),
+        Header: '',
+        id: 'id',
+        disableSortBy: true,
+        size: 'xs',
+        hidden: !userId,
+      },
       {
         Cell: ({
           row: {
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index b53731754f..b539ca126f 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -564,10 +564,15 @@ const favoriteApis = {
     method: 'GET',
     endpoint: '/api/v1/dashboard/favorite_status/',
   }),
+  tag: makeApi<Array<string | number>, FavoriteStatusResponse>({
+    requestType: 'rison',
+    method: 'GET',
+    endpoint: '/api/v1/tag/favorite_status/',
+  }),
 };
 
 export function useFavoriteStatus(
-  type: 'chart' | 'dashboard',
+  type: 'chart' | 'dashboard' | 'tag',
   ids: Array<string | number>,
   handleErrorMsg: (message: string) => void,
 ) {
diff --git a/superset/daos/tag.py b/superset/daos/tag.py
index 90b0134ca7..8e4437f49d 100644
--- a/superset/daos/tag.py
+++ b/superset/daos/tag.py
@@ -18,15 +18,26 @@ import logging
 from operator import and_
 from typing import Any, Optional
 
+from flask import g
 from sqlalchemy.exc import SQLAlchemyError
 
 from superset.daos.base import BaseDAO
 from superset.daos.exceptions import DAOCreateFailedError, DAODeleteFailedError
+from superset.exceptions import MissingUserContextException
 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
+from superset.tags.commands.exceptions import TagNotFoundError
+from superset.tags.models import (
+    get_tag,
+    ObjectTypes,
+    Tag,
+    TaggedObject,
+    TagTypes,
+    user_favorite_tag_table,
+)
+from superset.utils.core import get_user_id
 
 logger = logging.getLogger(__name__)
 
@@ -257,3 +268,98 @@ class TagDAO(BaseDAO[Tag]):
                 for obj in saved_queries
             )
         return results
+
+    @staticmethod
+    def favorite_tag_by_id_for_current_user(  # pylint: disable=invalid-name
+        tag_id: int,
+    ) -> None:
+        """
+        Marks a specific tag as a favorite for the current user.
+        This function will find the tag by the provided id,
+        create a new UserFavoriteTag object that represents
+        the user's preference, add that object to the database
+        session, and commit the session. It uses the currently
+        authenticated user from the global 'g' object.
+        Args:
+            tag_id: The id of the tag that is to be marked as
+                    favorite.
+        Raises:
+            Any exceptions raised by the find_by_id function,
+            the UserFavoriteTag constructor, or the database session's
+            add and commit methods will propagate up to the caller.
+        Returns:
+            None.
+        """
+        tag = TagDAO.find_by_id(tag_id)
+        user = g.user
+
+        if not user:
+            raise MissingUserContextException(message="User doesn't exist")
+        if not tag:
+            raise TagNotFoundError()
+
+        tag.users_favorited.append(user)
+        db.session.commit()
+
+    @staticmethod
+    def remove_user_favorite_tag(tag_id: int) -> None:
+        """
+        Removes a tag from the current user's favorite tags.
+
+        This function will find the tag by the provided id and remove the tag
+        from the user's list of favorite tags. It uses the currently authenticated
+        user from the global 'g' object.
+
+        Args:
+            tag_id: The id of the tag that is to be removed from the favorite tags.
+
+        Raises:
+            Any exceptions raised by the find_by_id function, the database session's
+            commit method will propagate up to the caller.
+
+        Returns:
+            None.
+        """
+        tag = TagDAO.find_by_id(tag_id)
+        user = g.user
+
+        if not user:
+            raise MissingUserContextException(message="User doesn't exist")
+        if not tag:
+            raise TagNotFoundError()
+
+        tag.users_favorited.remove(user)
+
+        # Commit to save the changes
+        db.session.commit()
+
+    @staticmethod
+    def favorited_ids(tags: list[Tag]) -> list[int]:
+        """
+        Returns the IDs of tags that the current user has favorited.
+
+        This function takes in a list of Tag objects, extracts their IDs, and checks
+        which of these IDs exist in the user_favorite_tag_table for the current user.
+        The function returns a list of these favorited tag IDs.
+
+        Args:
+            tags (list[Tag]): A list of Tag objects.
+
+        Returns:
+            list[Any]: A list of IDs corresponding to the tags that are favorited by
+            the current user.
+
+        Example:
+            favorited_ids([tag1, tag2, tag3])
+            Output: [tag_id1, tag_id3]   # if the current user has favorited tag1 and tag3
+        """
+        ids = [tag.id for tag in tags]
+        return [
+            star.tag_id
+            for star in db.session.query(user_favorite_tag_table.c.tag_id)
+            .filter(
+                user_favorite_tag_table.c.tag_id.in_(ids),
+                user_favorite_tag_table.c.user_id == get_user_id(),
+            )
+            .all()
+        ]
diff --git a/superset/exceptions.py b/superset/exceptions.py
index 018d1b6dfb..5f1df73c86 100644
--- a/superset/exceptions.py
+++ b/superset/exceptions.py
@@ -200,6 +200,10 @@ class DatabaseNotFound(SupersetException):
     status = 400
 
 
+class MissingUserContextException(SupersetException):
+    status = 422
+
+
 class QueryObjectValidationError(SupersetException):
     status = 400
 
diff --git a/superset/migrations/versions/2023-07-12_20-34_e0f6f91c2055_create_user_favorite_table.py b/superset/migrations/versions/2023-07-12_20-34_e0f6f91c2055_create_user_favorite_table.py
new file mode 100644
index 0000000000..5a519bae1a
--- /dev/null
+++ b/superset/migrations/versions/2023-07-12_20-34_e0f6f91c2055_create_user_favorite_table.py
@@ -0,0 +1,53 @@
+# 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.
+"""create_user_favorite_table
+
+Revision ID: e0f6f91c2055
+Revises: bf646a0c1501
+Create Date: 2023-07-12 20:34:57.553981
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "e0f6f91c2055"
+down_revision = "bf646a0c1501"
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "user_favorite_tag",
+        sa.Column("user_id", sa.Integer(), nullable=False),
+        sa.Column("tag_id", sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["tag_id"],
+            ["tag.id"],
+        ),
+        sa.ForeignKeyConstraint(
+            ["user_id"],
+            ["ab_user.id"],
+        ),
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    op.drop_table("user_favorite_tag")
diff --git a/superset/tags/api.py b/superset/tags/api.py
index f9aa7f7be9..21791a6ef0 100644
--- a/superset/tags/api.py
+++ b/superset/tags/api.py
@@ -23,6 +23,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
 
 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.delete import DeleteTaggedObjectCommand, DeleteTagsCommand
@@ -61,6 +62,9 @@ class TagRestApi(BaseSupersetModelRestApi):
         "get_all_objects",
         "add_objects",
         "delete_object",
+        "add_favorite",
+        "remove_favorite",
+        "favorite_status",
     }
 
     resource_name = "tag"
@@ -384,3 +388,150 @@ class TagRestApi(BaseSupersetModelRestApi):
                 exc_info=True,
             )
             return self.response_422(message=str(ex))
+
+    @expose("/favorite_status/", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @rison({"type": "array", "items": {"type": "integer"}})
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
+        f".favorite_status",
+        log_to_statsd=False,
+    )
+    def favorite_status(self, **kwargs: Any) -> Response:
+        """Favorite Stars for Dashboards
+        ---
+        get:
+          description: >-
+            Check favorited dashboards for current user
+          parameters:
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/get_fav_star_ids_schema'
+          responses:
+            200:
+              description:
+              content:
+                application/json:
+                  schema:
+                    $ref: "#/components/schemas/GetFavStarIdsSchema"
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            requested_ids = kwargs["rison"]
+            tags = TagDAO.find_by_ids(requested_ids)
+            users_favorited_tags = TagDAO.favorited_ids(tags)
+            res = [
+                {"id": request_id, "value": request_id in users_favorited_tags}
+                for request_id in requested_ids
+            ]
+            return self.response(200, result=res)
+        except TagNotFoundError:
+            return self.response_404()
+        except MissingUserContextException as ex:
+            return self.response_422(message=str(ex))
+
+    @expose("/<pk>/favorites/", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
+        f".add_favorite",
+        log_to_statsd=False,
+    )
+    def add_favorite(self, pk: int) -> Response:
+        """Marks the tag as favorite
+        ---
+        post:
+          description: >-
+            Marks the tag as favorite for the current user
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          responses:
+            200:
+              description: Tag added to favorites
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        type: object
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            TagDAO.favorite_tag_by_id_for_current_user(pk)
+            return self.response(200, result="OK")
+        except TagNotFoundError:
+            return self.response_404()
+        except MissingUserContextException as ex:
+            return self.response_422(message=str(ex))
+
+    @expose("/<pk>/favorites/", methods=("DELETE",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
+        f".remove_favorite",
+        log_to_statsd=False,
+    )
+    def remove_favorite(self, pk: int) -> Response:
+        """Remove the tag from the user favorite list
+        ---
+        delete:
+          description: >-
+            Remove the tag from the user favorite list
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+          responses:
+            200:
+              description: Tag removed from favorites
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        type: object
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            TagDAO.remove_user_favorite_tag(pk)
+            return self.response(200, result="OK")
+        except TagNotFoundError:
+            return self.response_404()
+        except MissingUserContextException as ex:
+            return self.response_422(message=str(ex))
diff --git a/superset/tags/models.py b/superset/tags/models.py
index 79022b7e6d..7e350061fa 100644
--- a/superset/tags/models.py
+++ b/superset/tags/models.py
@@ -20,11 +20,12 @@ import enum
 from typing import TYPE_CHECKING
 
 from flask_appbuilder import Model
-from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Text
+from sqlalchemy import Column, Enum, ForeignKey, Integer, String, Table, Text
 from sqlalchemy.engine.base import Connection
 from sqlalchemy.orm import relationship, Session, sessionmaker
 from sqlalchemy.orm.mapper import Mapper
 
+from superset import security_manager
 from superset.models.helpers import AuditMixinNullable
 
 if TYPE_CHECKING:
@@ -36,6 +37,13 @@ if TYPE_CHECKING:
 
 Session = sessionmaker(autoflush=False)
 
+user_favorite_tag_table = Table(
+    "user_favorite_tag",
+    Model.metadata,  # pylint: disable=no-member
+    Column("user_id", Integer, ForeignKey("ab_user.id")),
+    Column("tag_id", Integer, ForeignKey("tag.id")),
+)
+
 
 class TagTypes(enum.Enum):
 
@@ -82,6 +90,10 @@ class Tag(Model, AuditMixinNullable):
         "TaggedObject", back_populates="tag", overlaps="objects,tags"
     )
 
+    users_favorited = relationship(
+        security_manager.user_model, secondary=user_favorite_tag_table
+    )
+
 
 class TaggedObject(Model, AuditMixinNullable):
 
diff --git a/tests/integration_tests/tags/api_tests.py b/tests/integration_tests/tags/api_tests.py
index b047388a68..d1231db97e 100644
--- a/tests/integration_tests/tags/api_tests.py
+++ b/tests/integration_tests/tags/api_tests.py
@@ -18,12 +18,17 @@
 """Unit tests for Superset"""
 import json
 
+from flask import g
 import pytest
 import prison
 from sqlalchemy.sql import func
+from sqlalchemy import and_
 from superset.models.dashboard import Dashboard
 from superset.models.slice import Slice
 from superset.models.sql_lab import SavedQuery
+from superset.tags.models import user_favorite_tag_table
+from unittest.mock import patch
+
 
 import tests.integration_tests.test_app
 from superset import db, security_manager
@@ -372,3 +377,83 @@ class TestTagApi(SupersetTestCase):
         # check that tags are all gone
         tags = db.session.query(Tag).filter(Tag.name.in_(example_tag_names))
         self.assertEqual(tags.count(), 0)
+
+    @pytest.mark.usefixtures("create_tags")
+    def test_delete_favorite_tag(self):
+        self.login(username="admin")
+        user_id = self.get_user(username="admin").get_id()
+        tag = db.session.query(Tag).first()
+        uri = f"api/v1/tag/{tag.id}/favorites/"
+        tag = db.session.query(Tag).first()
+        rv = self.client.post(uri, follow_redirects=True)
+
+        self.assertEqual(rv.status_code, 200)
+        from sqlalchemy import and_
+        from superset.tags.models import user_favorite_tag_table
+        from flask import g
+
+        association_row = (
+            db.session.query(user_favorite_tag_table)
+            .filter(
+                and_(
+                    user_favorite_tag_table.c.tag_id == tag.id,
+                    user_favorite_tag_table.c.user_id == user_id,
+                )
+            )
+            .one_or_none()
+        )
+
+        assert association_row is not None
+
+        uri = f"api/v1/tag/{tag.id}/favorites/"
+        rv = self.client.delete(uri, follow_redirects=True)
+
+        self.assertEqual(rv.status_code, 200)
+        association_row = (
+            db.session.query(user_favorite_tag_table)
+            .filter(
+                and_(
+                    user_favorite_tag_table.c.tag_id == tag.id,
+                    user_favorite_tag_table.c.user_id == user_id,
+                )
+            )
+            .one_or_none()
+        )
+
+        assert association_row is None
+
+    @pytest.mark.usefixtures("create_tags")
+    def test_add_tag_not_found(self):
+        self.login(username="admin")
+        uri = f"api/v1/tag/123/favorites/"
+        rv = self.client.post(uri, follow_redirects=True)
+
+        self.assertEqual(rv.status_code, 404)
+
+    @pytest.mark.usefixtures("create_tags")
+    def test_delete_favorite_tag_not_found(self):
+        self.login(username="admin")
+        uri = f"api/v1/tag/123/favorites/"
+        rv = self.client.delete(uri, follow_redirects=True)
+
+        self.assertEqual(rv.status_code, 404)
+
+    @pytest.mark.usefixtures("create_tags")
+    @patch("superset.daos.tag.g")
+    def test_add_tag_user_not_found(self, flask_g):
+        self.login(username="admin")
+        flask_g.user = None
+        uri = f"api/v1/tag/123/favorites/"
+        rv = self.client.post(uri, follow_redirects=True)
+
+        self.assertEqual(rv.status_code, 422)
+
+    @pytest.mark.usefixtures("create_tags")
+    @patch("superset.daos.tag.g")
+    def test_delete_favorite_tag_user_not_found(self, flask_g):
+        self.login(username="admin")
+        flask_g.user = None
+        uri = f"api/v1/tag/123/favorites/"
+        rv = self.client.delete(uri, follow_redirects=True)
+
+        self.assertEqual(rv.status_code, 422)
diff --git a/tests/unit_tests/dao/tag_test.py b/tests/unit_tests/dao/tag_test.py
new file mode 100644
index 0000000000..3f5666d2b5
--- /dev/null
+++ b/tests/unit_tests/dao/tag_test.py
@@ -0,0 +1,146 @@
+# 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 collections.abc import Iterator
+
+import pytest
+from sqlalchemy.orm.session import Session
+
+
+def test_user_favorite_tag(mocker):
+    from superset.daos.tag import TagDAO
+
+    # Mock the behavior of TagDAO and g
+    mock_session = mocker.patch("superset.daos.tag.db.session")
+    mock_TagDAO = mocker.patch(
+        "superset.daos.tag.TagDAO"
+    )  # Replace with the actual path to TagDAO
+    mock_TagDAO.find_by_id.return_value = mocker.MagicMock(users_favorited=[])
+
+    mock_g = mocker.patch("superset.daos.tag.g")  # Replace with the actual path to g
+    mock_g.user = mocker.MagicMock()
+
+    # Call the function with a test tag_id
+    TagDAO.favorite_tag_by_id_for_current_user(123)
+
+    # Check that find_by_id was called with the right argument
+    mock_TagDAO.find_by_id.assert_called_once_with(123)
+
+    # Check that users_favorited was updated correctly
+    assert mock_TagDAO.find_by_id().users_favorited == [mock_g.user]
+
+    mock_session.commit.assert_called_once()
+
+
+def test_remove_user_favorite_tag(mocker):
+    from superset.daos.tag import TagDAO
+
+    # Mock the behavior of TagDAO and g
+    mock_session = mocker.patch("superset.daos.tag.db.session")
+    mock_TagDAO = mocker.patch("superset.daos.tag.TagDAO")
+    mock_tag = mocker.MagicMock(users_favorited=[])
+    mock_TagDAO.find_by_id.return_value = mock_tag
+
+    mock_g = mocker.patch("superset.daos.tag.g")  # Replace with the actual path to g
+    mock_user = mocker.MagicMock()
+    mock_g.user = mock_user
+
+    # Append the mock user to the tag's list of favorited users
+    mock_tag.users_favorited.append(mock_user)
+
+    # Call the function with a test tag_id
+    TagDAO.remove_user_favorite_tag(123)
+
+    # Check that find_by_id was called with the right argument
+    mock_TagDAO.find_by_id.assert_called_once_with(123)
+
+    # Check that users_favorited no longer contains the user
+    assert mock_user not in mock_tag.users_favorited
+
+    # Check that the session was committed
+    mock_session.commit.assert_called_once()
+
+
+def test_remove_user_favorite_tag_no_user(mocker):
+    from superset.daos.tag import TagDAO
+    from superset.exceptions import MissingUserContextException
+
+    # Mock the behavior of TagDAO and g
+    mock_session = mocker.patch("superset.daos.tag.db.session")
+    mock_TagDAO = mocker.patch("superset.daos.tag.TagDAO")
+    mock_tag = mocker.MagicMock(users_favorited=[])
+    mock_TagDAO.find_by_id.return_value = mock_tag
+
+    mock_g = mocker.patch("superset.daos.tag.g")  # Replace with the actual path to g
+
+    # Test with no user
+    mock_g.user = None
+    with pytest.raises(MissingUserContextException):
+        TagDAO.remove_user_favorite_tag(1)
+
+
+def test_remove_user_favorite_tag_exc_raise(mocker):
+    from superset.daos.tag import TagDAO
+    from superset.exceptions import MissingUserContextException
+
+    # Mock the behavior of TagDAO and g
+    mock_session = mocker.patch("superset.daos.tag.db.session")
+    mock_TagDAO = mocker.patch("superset.daos.tag.TagDAO")
+    mock_tag = mocker.MagicMock(users_favorited=[])
+    mock_TagDAO.find_by_id.return_value = mock_tag
+
+    mock_g = mocker.patch("superset.daos.tag.g")  # Replace with the actual path to g
+
+    # Test that exception is raised when commit fails
+    mock_session.commit.side_effect = Exception("DB Error")
+    with pytest.raises(Exception):
+        TagDAO.remove_user_favorite_tag(1)
+
+
+def test_user_favorite_tag_no_user(mocker):
+    from superset.daos.tag import TagDAO
+    from superset.exceptions import MissingUserContextException
+
+    # Mock the behavior of TagDAO and g
+    mock_session = mocker.patch("superset.daos.tag.db.session")
+    mock_TagDAO = mocker.patch("superset.daos.tag.TagDAO")
+    mock_tag = mocker.MagicMock(users_favorited=[])
+    mock_TagDAO.find_by_id.return_value = mock_tag
+
+    mock_g = mocker.patch("superset.daos.tag.g")  # Replace with the actual path to g
+
+    # Test with no user
+    mock_g.user = None
+    with pytest.raises(MissingUserContextException):
+        TagDAO.favorite_tag_by_id_for_current_user(1)
+
+
+def test_user_favorite_tag_exc_raise(mocker):
+    from superset.daos.tag import TagDAO
+    from superset.exceptions import MissingUserContextException
+
+    # Mock the behavior of TagDAO and g
+    mock_session = mocker.patch("superset.daos.tag.db.session")
+    mock_TagDAO = mocker.patch("superset.daos.tag.TagDAO")
+    mock_tag = mocker.MagicMock(users_favorited=[])
+    mock_TagDAO.find_by_id.return_value = mock_tag
+
+    mock_g = mocker.patch("superset.daos.tag.g")  # Replace with the actual path to g
+
+    # Test that exception is raised when commit fails
+    mock_session.commit.side_effect = Exception("DB Error")
+    with pytest.raises(Exception):
+        TagDAO.remove_user_favorite_tag(1)