You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by dp...@apache.org on 2020/10/01 10:46:57 UTC

[incubator-superset] branch master updated: feat: CRUD REST API for CSS Templates (#11114)

This is an automated email from the ASF dual-hosted git repository.

dpgaspar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new fdb26f6  feat: CRUD REST API for CSS Templates (#11114)
fdb26f6 is described below

commit fdb26f61314ad03e18feb7b7a9004fc177dd7e7c
Author: Daniel Vaz Gaspar <da...@gmail.com>
AuthorDate: Thu Oct 1 11:46:25 2020 +0100

    feat: CRUD REST API for CSS Templates (#11114)
    
    * feat: CSS Template CRUD API
    
    * fix API docs
    
    * fix copy pasta
    
    * lint
---
 superset/app.py                                |   2 +
 superset/css_templates/__init__.py             |  16 ++
 superset/css_templates/api.py                  | 133 +++++++++
 superset/css_templates/commands/__init__.py    |  16 ++
 superset/css_templates/commands/bulk_delete.py |  53 ++++
 superset/css_templates/commands/exceptions.py  |  27 ++
 superset/css_templates/dao.py                  |  45 ++++
 superset/css_templates/filters.py              |  40 +++
 superset/css_templates/schemas.py              |  33 +++
 tests/css_templates/__init__.py                |  16 ++
 tests/css_templates/api_tests.py               | 356 +++++++++++++++++++++++++
 11 files changed, 737 insertions(+)

diff --git a/superset/app.py b/superset/app.py
index 1ef5b30..e5aa287 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 0000000..13a8339
--- /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 0000000..4da895c
--- /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 0000000..13a8339
--- /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 0000000..40b2e80
--- /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 0000000..d950822
--- /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 0000000..8f9d36b
--- /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 0000000..e44382a
--- /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 0000000..e8243c2
--- /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 0000000..13a8339
--- /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 0000000..095c246
--- /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