diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 4ef8c55de38..9828ceab7e4 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -1008,11 +1008,11 @@ const DatasetList: FunctionComponent = ({ if (semanticViews.length) { promises.push( - ...semanticViews.map(sv => - SupersetClient.delete({ - endpoint: `/api/v1/semantic_view/${sv.id}`, - }), - ), + SupersetClient.delete({ + endpoint: `/api/v1/semantic_view/?q=${rison.encode( + semanticViews.map(({ id }) => id), + )}`, + }), ); } diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py index a69d35d0f5d..bdc13502f4d 100644 --- a/superset/commands/semantic_layer/delete.py +++ b/superset/commands/semantic_layer/delete.py @@ -79,3 +79,25 @@ class DeleteSemanticViewCommand(BaseCommand): self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id") if not self._model: raise SemanticViewNotFoundError() + + +class BulkDeleteSemanticViewCommand(BaseCommand): + def __init__(self, model_ids: list[int]): + self._model_ids = model_ids + self._models: list[SemanticView] = [] + + @transaction( + on_error=partial( + on_error, + catches=(SQLAlchemyError,), + reraise=SemanticViewDeleteFailedError, + ) + ) + def run(self) -> None: + self.validate() + SemanticViewDAO.delete(self._models) + + def validate(self) -> None: + self._models = SemanticViewDAO.find_by_ids(self._model_ids, id_column="id") + if len(self._models) != len(self._model_ids): + raise SemanticViewNotFoundError() diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index d24b87e7c04..b1935065b27 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -23,7 +23,7 @@ from flask import make_response, request, Response from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.api.schemas import get_list_schema from flask_appbuilder.models.sqla.interface import SQLAInterface -from flask_babel import lazy_gettext as t +from flask_babel import lazy_gettext as t, ngettext from marshmallow import ValidationError from pydantic import ValidationError as PydanticValidationError @@ -33,6 +33,7 @@ from superset.commands.semantic_layer.create import ( CreateSemanticViewCommand, ) from superset.commands.semantic_layer.delete import ( + BulkDeleteSemanticViewCommand, DeleteSemanticLayerCommand, DeleteSemanticViewCommand, ) @@ -55,6 +56,7 @@ from superset.commands.semantic_layer.update import ( ) from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO +from superset.datasets.schemas import get_delete_ids_schema from superset.models.core import Database from superset.semantic_layers.models import SemanticLayer, SemanticView from superset.semantic_layers.registry import registry @@ -171,7 +173,7 @@ class SemanticViewRestApi(BaseSupersetModelRestApi): allow_browser_login = True class_permission_name = "SemanticView" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP - include_route_methods = {"put", "post", "delete"} + include_route_methods = {"put", "post", "delete", "bulk_delete"} edit_model_schema = SemanticViewPutSchema() @@ -368,6 +370,66 @@ class SemanticViewRestApi(BaseSupersetModelRestApi): ) return self.response_422(message=str(ex)) + @expose("/", methods=("DELETE",)) + @protect() + @statsd_metrics + @rison(get_delete_ids_schema) + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete", + log_to_statsd=False, + ) + def bulk_delete(self, **kwargs: Any) -> Response: + """Bulk delete semantic views. + --- + delete: + summary: Bulk delete semantic views + parameters: + - in: query + name: q + content: + application/json: + schema: + $ref: '#/components/schemas/get_delete_ids_schema' + responses: + 200: + description: Semantic views deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + """ + item_ids: list[int] = kwargs["rison"] + try: + BulkDeleteSemanticViewCommand(item_ids).run() + return self.response( + 200, + message=ngettext( + "Deleted %(num)d semantic view", + "Deleted %(num)d semantic views", + num=len(item_ids), + ), + ) + except SemanticViewNotFoundError: + return self.response_404() + except SemanticViewDeleteFailedError as ex: + logger.error( + "Error bulk deleting semantic views: %s", + str(ex), + exc_info=True, + ) + return self.response_422(message=str(ex)) + class SemanticLayerRestApi(BaseSupersetApi): resource_name = "semantic_layer" diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py index 288f22710fc..36f39895eb1 100644 --- a/tests/unit_tests/commands/semantic_layer/delete_test.py +++ b/tests/unit_tests/commands/semantic_layer/delete_test.py @@ -81,3 +81,35 @@ def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None: with pytest.raises(SemanticViewNotFoundError): DeleteSemanticViewCommand(999).run() + + +def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None: + """Test successful bulk deletion of semantic views.""" + mock_models = [MagicMock(), MagicMock()] + + dao = mocker.patch( + "superset.commands.semantic_layer.delete.SemanticViewDAO", + ) + dao.find_by_ids.return_value = mock_models + + from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand + + BulkDeleteSemanticViewCommand([1, 2]).run() + + dao.find_by_ids.assert_called_once_with([1, 2], id_column="id") + dao.delete.assert_called_once_with(mock_models) + + +def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None: + """Test that SemanticViewNotFoundError is raised when any id is missing.""" + dao = mocker.patch( + "superset.commands.semantic_layer.delete.SemanticViewDAO", + ) + # Only one model returned for two requested ids + dao.find_by_ids.return_value = [MagicMock()] + + from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand + from superset.commands.semantic_layer.exceptions import SemanticViewNotFoundError + + with pytest.raises(SemanticViewNotFoundError): + BulkDeleteSemanticViewCommand([1, 2]).run() diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py index c65cc6a7780..5dfcb6e25e6 100644 --- a/tests/unit_tests/semantic_layers/api_test.py +++ b/tests/unit_tests/semantic_layers/api_test.py @@ -1718,6 +1718,73 @@ def test_delete_semantic_view_failed( assert response.status_code == 422 +# ============================================================================= +# SemanticViewRestApi.bulk_delete tests +# ============================================================================= + + +@SEMANTIC_LAYERS_APP +def test_bulk_delete_semantic_view( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test DELETE / deletes multiple semantic views and returns a count message.""" + import prison as rison_lib + + mock_command = mocker.patch( + "superset.semantic_layers.api.BulkDeleteSemanticViewCommand", + ) + mock_command.return_value.run.return_value = None + + q = rison_lib.dumps([1, 2, 3]) + response = client.delete(f"/api/v1/semantic_view/?q={q}") + + assert response.status_code == 200 + assert "3" in response.json["message"] + mock_command.assert_called_once_with([1, 2, 3]) + + +@SEMANTIC_LAYERS_APP +def test_bulk_delete_semantic_view_not_found( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test DELETE / returns 404 when any id is missing.""" + import prison as rison_lib + + mock_command = mocker.patch( + "superset.semantic_layers.api.BulkDeleteSemanticViewCommand", + ) + mock_command.return_value.run.side_effect = SemanticViewNotFoundError() + + q = rison_lib.dumps([1, 999]) + response = client.delete(f"/api/v1/semantic_view/?q={q}") + + assert response.status_code == 404 + + +@SEMANTIC_LAYERS_APP +def test_bulk_delete_semantic_view_failed( + client: Any, + full_api_access: None, + mocker: MockerFixture, +) -> None: + """Test DELETE / returns 422 when deletion fails.""" + import prison as rison_lib + + mock_command = mocker.patch( + "superset.semantic_layers.api.BulkDeleteSemanticViewCommand", + ) + mock_command.return_value.run.side_effect = SemanticViewDeleteFailedError() + + q = rison_lib.dumps([1, 2]) + response = client.delete(f"/api/v1/semantic_view/?q={q}") + + assert response.status_code == 422 + + # ============================================================================= # SemanticLayerRestApi.views tests # =============================================================================