diff --git a/superset/app.py b/superset/app.py index 1ef5b30531..e5aa287020 100644 --- a/superset/app.py +++ b/superset/app.py @@ -140,6 +140,7 @@ class SupersetAppInitializer: TableColumnInlineView, TableModelView, ) + from superset.css_templates.api import CssTemplateRestApi from superset.dashboards.api import DashboardRestApi from superset.databases.api import DatabaseRestApi from superset.datasets.api import DatasetRestApi @@ -197,6 +198,7 @@ class SupersetAppInitializer: # appbuilder.add_api(CacheRestApi) appbuilder.add_api(ChartRestApi) + appbuilder.add_api(CssTemplateRestApi) appbuilder.add_api(DashboardRestApi) appbuilder.add_api(DatabaseRestApi) appbuilder.add_api(DatasetRestApi) diff --git a/superset/css_templates/__init__.py b/superset/css_templates/__init__.py new file mode 100644 index 0000000000..13a83393a9 --- /dev/null +++ b/superset/css_templates/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/superset/css_templates/api.py b/superset/css_templates/api.py new file mode 100644 index 0000000000..4da895c297 --- /dev/null +++ b/superset/css_templates/api.py @@ -0,0 +1,133 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import logging +from typing import Any + +from flask import g, Response +from flask_appbuilder.api import expose, protect, rison, safe +from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_babel import ngettext + +from superset.constants import RouteMethod +from superset.css_templates.commands.bulk_delete import BulkDeleteCssTemplateCommand +from superset.css_templates.commands.exceptions import ( + CssTemplateBulkDeleteFailedError, + CssTemplateNotFoundError, +) +from superset.css_templates.filters import CssTemplateAllTextFilter +from superset.css_templates.schemas import ( + get_delete_ids_schema, + openapi_spec_methods_override, +) +from superset.models.core import CssTemplate +from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics + +logger = logging.getLogger(__name__) + + +class CssTemplateRestApi(BaseSupersetModelRestApi): + datamodel = SQLAInterface(CssTemplate) + + include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { + "bulk_delete", # not using RouteMethod since locally defined + } + class_permission_name = "CssTemplateModelView" + resource_name = "css_template" + allow_browser_login = True + + show_columns = [ + "created_by.first_name", + "created_by.id", + "created_by.last_name", + "css", + "id", + "template_name", + ] + list_columns = [ + "changed_on_delta_humanized", + "created_on", + "created_by.first_name", + "created_by.id", + "created_by.last_name", + "css", + "id", + "template_name", + ] + add_columns = ["css", "template_name"] + edit_columns = add_columns + order_columns = ["template_name"] + + search_filters = {"template_name": [CssTemplateAllTextFilter]} + + apispec_parameter_schemas = { + "get_delete_ids_schema": get_delete_ids_schema, + } + openapi_spec_tag = "CSS Templates" + openapi_spec_methods = openapi_spec_methods_override + + @expose("/", methods=["DELETE"]) + @protect() + @safe + @statsd_metrics + @rison(get_delete_ids_schema) + def bulk_delete(self, **kwargs: Any) -> Response: + """Delete bulk CSS Templates + --- + delete: + description: >- + Deletes multiple css templates in a bulk operation. + parameters: + - in: query + name: q + content: + application/json: + schema: + $ref: '#/components/schemas/get_delete_ids_schema' + responses: + 200: + description: CSS templates bulk delete + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + item_ids = kwargs["rison"] + try: + BulkDeleteCssTemplateCommand(g.user, item_ids).run() + return self.response( + 200, + message=ngettext( + "Deleted %(num)d css template", + "Deleted %(num)d css templates", + num=len(item_ids), + ), + ) + except CssTemplateNotFoundError: + return self.response_404() + except CssTemplateBulkDeleteFailedError as ex: + return self.response_422(message=str(ex)) diff --git a/superset/css_templates/commands/__init__.py b/superset/css_templates/commands/__init__.py new file mode 100644 index 0000000000..13a83393a9 --- /dev/null +++ b/superset/css_templates/commands/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/superset/css_templates/commands/bulk_delete.py b/superset/css_templates/commands/bulk_delete.py new file mode 100644 index 0000000000..40b2e800cd --- /dev/null +++ b/superset/css_templates/commands/bulk_delete.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. +import logging +from typing import List, Optional + +from flask_appbuilder.security.sqla.models import User + +from superset.commands.base import BaseCommand +from superset.css_templates.commands.exceptions import ( + CssTemplateBulkDeleteFailedError, + CssTemplateNotFoundError, +) +from superset.css_templates.dao import CssTemplateDAO +from superset.dao.exceptions import DAODeleteFailedError +from superset.models.core import CssTemplate + +logger = logging.getLogger(__name__) + + +class BulkDeleteCssTemplateCommand(BaseCommand): + def __init__(self, user: User, model_ids: List[int]): + self._actor = user + self._model_ids = model_ids + self._models: Optional[List[CssTemplate]] = None + + def run(self) -> None: + self.validate() + try: + CssTemplateDAO.bulk_delete(self._models) + return None + except DAODeleteFailedError as ex: + logger.exception(ex.exception) + raise CssTemplateBulkDeleteFailedError() + + def validate(self) -> None: + # Validate/populate model exists + self._models = CssTemplateDAO.find_by_ids(self._model_ids) + if not self._models or len(self._models) != len(self._model_ids): + raise CssTemplateNotFoundError() diff --git a/superset/css_templates/commands/exceptions.py b/superset/css_templates/commands/exceptions.py new file mode 100644 index 0000000000..d950822cfa --- /dev/null +++ b/superset/css_templates/commands/exceptions.py @@ -0,0 +1,27 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from flask_babel import lazy_gettext as _ + +from superset.commands.exceptions import CommandException, DeleteFailedError + + +class CssTemplateBulkDeleteFailedError(DeleteFailedError): + message = _("CSS template could not be deleted.") + + +class CssTemplateNotFoundError(CommandException): + message = _("CSS template not found.") diff --git a/superset/css_templates/dao.py b/superset/css_templates/dao.py new file mode 100644 index 0000000000..8f9d36be3d --- /dev/null +++ b/superset/css_templates/dao.py @@ -0,0 +1,45 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +import logging +from typing import List, Optional + +from sqlalchemy.exc import SQLAlchemyError + +from superset.dao.base import BaseDAO +from superset.dao.exceptions import DAODeleteFailedError +from superset.extensions import db +from superset.models.core import CssTemplate + +logger = logging.getLogger(__name__) + + +class CssTemplateDAO(BaseDAO): + model_cls = CssTemplate + + @staticmethod + def bulk_delete(models: Optional[List[CssTemplate]], commit: bool = True) -> None: + item_ids = [model.id for model in models] if models else [] + try: + db.session.query(CssTemplate).filter(CssTemplate.id.in_(item_ids)).delete( + synchronize_session="fetch" + ) + if commit: + db.session.commit() + except SQLAlchemyError: + if commit: + db.session.rollback() + raise DAODeleteFailedError() diff --git a/superset/css_templates/filters.py b/superset/css_templates/filters.py new file mode 100644 index 0000000000..e44382a7a7 --- /dev/null +++ b/superset/css_templates/filters.py @@ -0,0 +1,40 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from typing import Any + +from flask_babel import lazy_gettext as _ +from sqlalchemy import or_ +from sqlalchemy.orm.query import Query + +from superset.models.core import CssTemplate +from superset.views.base import BaseFilter + + +class CssTemplateAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods + name = _("All Text") + arg_name = "css_template_all_text" + + def apply(self, query: Query, value: Any) -> Query: + if not value: + return query + ilike_value = f"%{value}%" + return query.filter( + or_( + CssTemplate.template_name.ilike(ilike_value), + CssTemplate.css.ilike(ilike_value), + ) + ) diff --git a/superset/css_templates/schemas.py b/superset/css_templates/schemas.py new file mode 100644 index 0000000000..e8243c2079 --- /dev/null +++ b/superset/css_templates/schemas.py @@ -0,0 +1,33 @@ +# 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. + +openapi_spec_methods_override = { + "get": {"get": {"description": "Get a CSS template"}}, + "get_list": { + "get": { + "description": "Get a list of CSS templates, use Rison or JSON " + "query parameters for filtering, sorting," + " pagination and for selecting specific" + " columns and metadata.", + } + }, + "post": {"post": {"description": "Create a CSS template"}}, + "put": {"put": {"description": "Update a CSS template"}}, + "delete": {"delete": {"description": "Delete CSS template"}}, +} + +get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}} diff --git a/tests/css_templates/__init__.py b/tests/css_templates/__init__.py new file mode 100644 index 0000000000..13a83393a9 --- /dev/null +++ b/tests/css_templates/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/tests/css_templates/api_tests.py b/tests/css_templates/api_tests.py new file mode 100644 index 0000000000..095c2464cb --- /dev/null +++ b/tests/css_templates/api_tests.py @@ -0,0 +1,356 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# isort:skip_file +"""Unit tests for Superset""" +import json +import pytest +import prison +from sqlalchemy.sql import func + +import tests.test_app +from superset import db +from superset.models.core import CssTemplate +from superset.utils.core import get_example_database + +from tests.base_tests import SupersetTestCase + + +CSS_TEMPLATES_FIXTURE_COUNT = 5 + + +class TestCssTemplateApi(SupersetTestCase): + def insert_css_template( + self, template_name: str, css: str, created_by_username: str = "admin", + ) -> CssTemplate: + admin = self.get_user(created_by_username) + css_template = CssTemplate( + template_name=template_name, css=css, created_by=admin + ) + db.session.add(css_template) + db.session.commit() + return css_template + + @pytest.fixture() + def create_css_templates(self): + with self.create_app().app_context(): + css_templates = [] + for cx in range(CSS_TEMPLATES_FIXTURE_COUNT): + css_templates.append( + self.insert_css_template( + template_name=f"template_name{cx}", css=f"css{cx}" + ) + ) + yield css_templates + + # rollback changes + for css_template in css_templates: + db.session.delete(css_template) + db.session.commit() + + @pytest.mark.usefixtures("create_css_templates") + def test_get_list_css_template(self): + """ + CSS Template API: Test get list css template + """ + css_templates = db.session.query(CssTemplate).all() + + self.login(username="admin") + uri = f"api/v1/css_template/" + rv = self.get_assert_metric(uri, "get_list") + assert rv.status_code == 200 + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == len(css_templates) + expected_columns = [ + "changed_on_delta_humanized", + "created_on", + "created_by", + "template_name", + "css", + ] + for expected_column in expected_columns: + assert expected_column in data["result"][0] + + @pytest.mark.usefixtures("create_css_templates") + def test_get_list_sort_css_template(self): + """ + CSS Template API: Test get list and sort CSS Template + """ + css_templates = ( + db.session.query(CssTemplate) + .order_by(CssTemplate.template_name.asc()) + .all() + ) + self.login(username="admin") + query_string = {"order_column": "template_name", "order_direction": "asc"} + uri = f"api/v1/css_template/?q={prison.dumps(query_string)}" + rv = self.get_assert_metric(uri, "get_list") + assert rv.status_code == 200 + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == len(css_templates) + for i, query in enumerate(css_templates): + assert query.template_name == data["result"][i]["template_name"] + + @pytest.mark.usefixtures("create_css_templates") + def test_get_list_custom_filter_css_template(self): + """ + CSS Template API: Test get list and custom filter + """ + self.login(username="admin") + + all_css_templates = ( + db.session.query(CssTemplate).filter(CssTemplate.css.ilike("%css2%")).all() + ) + query_string = { + "filters": [ + { + "col": "template_name", + "opr": "css_template_all_text", + "value": "css2", + } + ], + } + uri = f"api/v1/css_template/?q={prison.dumps(query_string)}" + rv = self.get_assert_metric(uri, "get_list") + assert rv.status_code == 200 + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == len(all_css_templates) + + all_css_templates = ( + db.session.query(CssTemplate) + .filter(CssTemplate.template_name.ilike("%template_name3%")) + .all() + ) + query_string = { + "filters": [ + { + "col": "template_name", + "opr": "css_template_all_text", + "value": "template_name3", + } + ], + } + uri = f"api/v1/css_template/?q={prison.dumps(query_string)}" + rv = self.get_assert_metric(uri, "get_list") + assert rv.status_code == 200 + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == len(all_css_templates) + + def test_info_css_template(self): + """ + CssTemplate API: Test info + """ + self.login(username="admin") + uri = f"api/v1/css_template/_info" + rv = self.get_assert_metric(uri, "info") + assert rv.status_code == 200 + + @pytest.mark.usefixtures("create_css_templates") + def test_get_css_template(self): + """ + CSS Template API: Test get CSS Template + """ + css_template = ( + db.session.query(CssTemplate) + .filter(CssTemplate.template_name == "template_name1") + .one_or_none() + ) + self.login(username="admin") + uri = f"api/v1/css_template/{css_template.id}" + rv = self.get_assert_metric(uri, "get") + assert rv.status_code == 200 + + expected_result = { + "id": css_template.id, + "template_name": "template_name1", + "css": "css1", + "created_by": { + "first_name": css_template.created_by.first_name, + "id": css_template.created_by.id, + "last_name": css_template.created_by.last_name, + }, + } + data = json.loads(rv.data.decode("utf-8")) + for key, value in data["result"].items(): + assert value == expected_result[key] + + @pytest.mark.usefixtures("create_css_templates") + def test_get_css_template_not_found(self): + """ + CSS Template API: Test get CSS Template not found + """ + max_id = db.session.query(func.max(CssTemplate.id)).scalar() + self.login(username="admin") + uri = f"api/v1/css_template/{max_id + 1}" + rv = self.client.get(uri) + assert rv.status_code == 404 + + def test_create_css_template(self): + """ + CSS Template API: Test create + """ + post_data = { + "template_name": "template_name_create", + "css": "css_create", + } + + self.login(username="admin") + uri = f"api/v1/css_template/" + rv = self.client.post(uri, json=post_data) + data = json.loads(rv.data.decode("utf-8")) + assert rv.status_code == 201 + + css_template_id = data.get("id") + model = db.session.query(CssTemplate).get(css_template_id) + for key in post_data: + assert getattr(model, key) == data["result"][key] + + # Rollback changes + db.session.delete(model) + db.session.commit() + + @pytest.mark.usefixtures("create_css_templates") + def test_update_css_template(self): + """ + CSS Template API: Test update + """ + css_template = ( + db.session.query(CssTemplate) + .filter(CssTemplate.template_name == "template_name1") + .all()[0] + ) + + put_data = { + "template_name": "template_name_changed", + "css": "css_changed", + } + + self.login(username="admin") + uri = f"api/v1/css_template/{css_template.id}" + rv = self.client.put(uri, json=put_data) + assert rv.status_code == 200 + + model = db.session.query(CssTemplate).get(css_template.id) + assert model.template_name == "template_name_changed" + assert model.css == "css_changed" + + @pytest.mark.usefixtures("create_css_templates") + def test_update_css_template_not_found(self): + """ + CSS Template API: Test update not found + """ + max_id = db.session.query(func.max(CssTemplate.id)).scalar() + self.login(username="admin") + + put_data = { + "template_name": "template_name_changed", + "css": "css_changed", + } + + uri = f"api/v1/css_template/{max_id + 1}" + rv = self.client.put(uri, json=put_data) + assert rv.status_code == 404 + + @pytest.mark.usefixtures("create_css_templates") + def test_delete_css_template(self): + """ + CSS Template API: Test delete + """ + css_template = ( + db.session.query(CssTemplate) + .filter(CssTemplate.template_name == "template_name1") + .one_or_none() + ) + + self.login(username="admin") + uri = f"api/v1/css_template/{css_template.id}" + rv = self.client.delete(uri) + assert rv.status_code == 200 + + model = db.session.query(CssTemplate).get(css_template.id) + assert model is None + + @pytest.mark.usefixtures("create_css_templates") + def test_delete_css_template_not_found(self): + """ + CSS Template API: Test delete not found + """ + max_id = db.session.query(func.max(CssTemplate.id)).scalar() + self.login(username="admin") + uri = f"api/v1/css_template/{max_id + 1}" + rv = self.client.delete(uri) + assert rv.status_code == 404 + + @pytest.mark.usefixtures("create_css_templates") + def test_delete_bulk_css_templates(self): + """ + CSS Template API: Test delete bulk + """ + css_templates = db.session.query(CssTemplate).all() + css_template_ids = [css_template.id for css_template in css_templates] + + self.login(username="admin") + uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + assert rv.status_code == 200 + response = json.loads(rv.data.decode("utf-8")) + expected_response = { + "message": f"Deleted {len(css_template_ids)} css templates" + } + assert response == expected_response + css_templates = db.session.query(CssTemplate).all() + assert css_templates == [] + + @pytest.mark.usefixtures("create_css_templates") + def test_delete_one_bulk_css_templates(self): + """ + CSS Template API: Test delete one in bulk + """ + css_template = db.session.query(CssTemplate).first() + css_template_ids = [css_template.id] + + self.login(username="admin") + uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + assert rv.status_code == 200 + response = json.loads(rv.data.decode("utf-8")) + expected_response = {"message": f"Deleted {len(css_template_ids)} css template"} + assert response == expected_response + css_template_ = db.session.query(CssTemplate).get(css_template_ids[0]) + assert css_template_ is None + + def test_delete_bulk_css_template_bad_request(self): + """ + CSS Template API: Test delete bulk bad request + """ + css_template_ids = [1, "a"] + self.login(username="admin") + uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + assert rv.status_code == 400 + + @pytest.mark.usefixtures("create_css_templates") + def test_delete_bulk_css_template_not_found(self): + """ + CSS Template API: Test delete bulk not found + """ + max_id = db.session.query(func.max(CssTemplate.id)).scalar() + + css_template_ids = [max_id + 1, max_id + 2] + self.login(username="admin") + uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}" + rv = self.delete_assert_metric(uri, "bulk_delete") + assert rv.status_code == 404