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 6f69212  feat: annotations REST API (#11344)
6f69212 is described below

commit 6f69212f2880ab88529e6870eba88f1d61f124b9
Author: Daniel Vaz Gaspar <[email protected]>
AuthorDate: Fri Oct 23 09:41:17 2020 +0100

    feat: annotations REST API (#11344)
    
    * feat(api): annotations and layers CRUD REST API
    
    * lint
    
    * annotations API and tests
    
    * fix openapi spec
    
    * fix openapi spec
    
    * fix openapi spec
    
    * annotations bulk delete
    
    * fix openapi spec
    
    * fix older tests
    
    * fix older tests
    
    * small lint fixes
    
    * layer_id to pk to keep broad coherence on openapi spec
    
    * fix openapi spec
    
    * one more test and validation
    
    * fix test name
    
    * fix test
    
    * fix bulk delete
    
    * add name validation
    
    * annotation uniqueness validation
    
    * lint
    
    * add sorting reqs and tests
    
    * add statsd metrics
    
    * Update superset/annotation_layers/annotations/dao.py
    
    Co-authored-by: Ville Brofeldt <[email protected]>
    
    * Update tests/core_tests.py
    
    Co-authored-by: Ville Brofeldt <[email protected]>
    
    * address comments
    
    * address my comment :)
    
    * fix, address comments
    
    * lint
    
    Co-authored-by: riahk <[email protected]>
    Co-authored-by: Ville Brofeldt <[email protected]>
---
 superset/annotation_layers/__init__.py             |  16 +
 superset/annotation_layers/annotations/api.py      | 483 ++++++++++++++
 .../annotations/commands/__init__.py               |  16 +
 .../annotations/commands/bulk_delete.py            |  53 ++
 .../annotations/commands/create.py                 |  80 +++
 .../annotations/commands/delete.py                 |  54 ++
 .../annotations/commands/exceptions.py             |  72 +++
 .../annotations/commands/update.py                 |  92 +++
 superset/annotation_layers/annotations/dao.py      |  65 ++
 superset/annotation_layers/annotations/filters.py  |  40 ++
 superset/annotation_layers/annotations/schemas.py  |  80 +++
 superset/annotation_layers/api.py                  | 327 ++++++++++
 superset/annotation_layers/commands/__init__.py    |  16 +
 superset/annotation_layers/commands/bulk_delete.py |  56 ++
 superset/annotation_layers/commands/create.py      |  61 ++
 superset/annotation_layers/commands/delete.py      |  57 ++
 superset/annotation_layers/commands/exceptions.py  |  66 ++
 superset/annotation_layers/commands/update.py      |  70 ++
 superset/annotation_layers/dao.py                  |  79 +++
 superset/annotation_layers/filters.py              |  42 ++
 superset/annotation_layers/schemas.py              |  52 ++
 superset/app.py                                    |   4 +
 superset/models/annotations.py                     |   3 +
 tests/annotation_layers/__init__.py                |  16 +
 tests/annotation_layers/api_tests.py               | 719 +++++++++++++++++++++
 tests/core_tests.py                                |   9 +-
 26 files changed, 2626 insertions(+), 2 deletions(-)

diff --git a/superset/annotation_layers/__init__.py 
b/superset/annotation_layers/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/superset/annotation_layers/__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/annotation_layers/annotations/api.py 
b/superset/annotation_layers/annotations/api.py
new file mode 100644
index 0000000..bc39f5c
--- /dev/null
+++ b/superset/annotation_layers/annotations/api.py
@@ -0,0 +1,483 @@
+# 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, Dict
+
+from flask import g, request, Response
+from flask_appbuilder.api import expose, permission_name, protect, rison, safe
+from flask_appbuilder.api.schemas import get_item_schema, get_list_schema
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_babel import ngettext
+from marshmallow import ValidationError
+
+from superset.annotation_layers.annotations.commands.bulk_delete import (
+    BulkDeleteAnnotationCommand,
+)
+from superset.annotation_layers.annotations.commands.create import (
+    CreateAnnotationCommand,
+)
+from superset.annotation_layers.annotations.commands.delete import (
+    DeleteAnnotationCommand,
+)
+from superset.annotation_layers.annotations.commands.exceptions import (
+    AnnotationBulkDeleteFailedError,
+    AnnotationCreateFailedError,
+    AnnotationDeleteFailedError,
+    AnnotationInvalidError,
+    AnnotationNotFoundError,
+    AnnotationUpdateFailedError,
+)
+from superset.annotation_layers.annotations.commands.update import (
+    UpdateAnnotationCommand,
+)
+from superset.annotation_layers.annotations.filters import 
AnnotationAllTextFilter
+from superset.annotation_layers.annotations.schemas import (
+    AnnotationPostSchema,
+    AnnotationPutSchema,
+    get_delete_ids_schema,
+    openapi_spec_methods_override,
+)
+from superset.annotation_layers.commands.exceptions import 
AnnotationLayerNotFoundError
+from superset.constants import RouteMethod
+from superset.models.annotations import Annotation
+from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics
+
+logger = logging.getLogger(__name__)
+
+
+class AnnotationRestApi(BaseSupersetModelRestApi):
+    datamodel = SQLAInterface(Annotation)
+
+    include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
+        "bulk_delete",  # not using RouteMethod since locally defined
+    }
+    class_permission_name = "AnnotationLayerModelView"
+    resource_name = "annotation_layer"
+    allow_browser_login = True
+
+    show_columns = [
+        "short_descr",
+        "long_descr",
+        "start_dttm",
+        "end_dttm",
+        "json_metadata",
+        "layer.id",
+        "layer.name",
+    ]
+    list_columns = [
+        "short_descr",
+        "created_by.id",
+        "created_by.first_name",
+        "changed_by.id",
+        "changed_by.first_name",
+        "changed_on_delta_humanized",
+        "start_dttm",
+        "end_dttm",
+    ]
+    add_columns = [
+        "short_descr",
+        "long_descr",
+        "start_dttm",
+        "end_dttm",
+        "json_metadata",
+    ]
+    add_model_schema = AnnotationPostSchema()
+    edit_model_schema = AnnotationPutSchema()
+    edit_columns = add_columns
+    order_columns = [
+        "short_descr",
+        "created_by.first_name",
+        "changed_by.first_name",
+        "changed_on_delta_humanized",
+        "start_dttm",
+        "end_dttm",
+    ]
+
+    search_filters = {"short_descr": [AnnotationAllTextFilter]}
+
+    apispec_parameter_schemas = {
+        "get_delete_ids_schema": get_delete_ids_schema,
+    }
+    openapi_spec_tag = "Annotation Layers"
+    openapi_spec_methods = openapi_spec_methods_override
+
+    @staticmethod
+    def _apply_layered_relation_to_rison(layer_id: int, rison_parameters) -> 
None:
+        if "filters" not in rison_parameters:
+            rison_parameters["filters"] = []
+        rison_parameters["filters"].append(
+            {"col": "layer", "opr": "rel_o_m", "value": layer_id}
+        )
+
+    @expose("/<int:pk>/annotation/", methods=["GET"])
+    @protect()
+    @safe
+    @permission_name("get")
+    @rison(get_list_schema)
+    def get_list(self, pk: int, **kwargs: Dict[str, Any]) -> Response:
+        """Get a list of annotations
+        ---
+        get:
+          description: >-
+            Get a list of annotations
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            description: The annotation layer id for this annotation
+            name: pk
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/get_list_schema'
+          responses:
+            200:
+              description: Items from Annotations
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      ids:
+                        description: >-
+                          A list of annotation ids
+                        type: array
+                        items:
+                          type: string
+                      count:
+                        description: >-
+                          The total record count on the backend
+                        type: number
+                      result:
+                        description: >-
+                          The result from the get list query
+                        type: array
+                        items:
+                          $ref: 
'#/components/schemas/{{self.__class__.__name__}}.get_list'  # noqa
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        self._apply_layered_relation_to_rison(pk, kwargs["rison"])
+        return self.get_list_headless(**kwargs)
+
+    @expose("/<int:pk>/annotation/<int:annotation_id>", methods=["GET"])
+    @protect()
+    @safe
+    @permission_name("get")
+    @rison(get_item_schema)
+    def get(self, pk: int, annotation_id: int, **kwargs: Dict[str, Any]) -> 
Response:
+        """Get item from Model
+        ---
+        get:
+          description: >-
+            Get an item model
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+            description: The annotation layer pk for this annotation
+          - in: path
+            schema:
+              type: integer
+            name: annotation_id
+            description: The annotation pk
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/get_item_schema'
+          responses:
+            200:
+              description: Item from Model
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        description: The item id
+                        type: string
+                      result:
+                        $ref: 
'#/components/schemas/{{self.__class__.__name__}}.get'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        self._apply_layered_relation_to_rison(pk, kwargs["rison"])
+        return self.get_headless(annotation_id, **kwargs)
+
+    @expose("/<int:pk>/annotation/", methods=["POST"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("post")
+    def post(self, pk: int) -> Response:
+        """Creates a new Annotation
+        ---
+        post:
+          description: >-
+            Create a new Annotation
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+            description: The annotation layer pk for this annotation
+          requestBody:
+            description: Annotation schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+          responses:
+            201:
+              description: Annotation added
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: 
'#/components/schemas/{{self.__class__.__name__}}.post'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        try:
+            item = self.add_model_schema.load(request.json)
+            item["layer"] = pk
+        # This validates custom Schema with custom validations
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+        try:
+            new_model = CreateAnnotationCommand(g.user, item).run()
+            return self.response(201, id=new_model.id, result=item)
+        except AnnotationLayerNotFoundError as ex:
+            return self.response_400(message=str(ex))
+        except AnnotationInvalidError as ex:
+            return self.response_422(message=ex.normalized_messages())
+        except AnnotationCreateFailedError as ex:
+            logger.error(
+                "Error creating annotation %s: %s", self.__class__.__name__, 
str(ex)
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<int:pk>/annotation/<int:annotation_id>", methods=["PUT"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("put")
+    def put(self, pk: int, annotation_id: int) -> Response:
+        """Updates an Annotation
+        ---
+        put:
+          description: >-
+            Update an annotation
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+            description: The annotation layer pk for this annotation
+          - in: path
+            schema:
+              type: integer
+            name: annotation_id
+            description: The annotation pk for this annotation
+          requestBody:
+            description: Annotation schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+          responses:
+            200:
+              description: Annotation changed
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: 
'#/components/schemas/{{self.__class__.__name__}}.put'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        try:
+            item = self.edit_model_schema.load(request.json)
+            item["layer"] = pk
+        # This validates custom Schema with custom validations
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+        try:
+            new_model = UpdateAnnotationCommand(g.user, annotation_id, 
item).run()
+            return self.response(200, id=new_model.id, result=item)
+        except (AnnotationNotFoundError, AnnotationLayerNotFoundError) as ex:
+            return self.response_404()
+        except AnnotationInvalidError as ex:
+            return self.response_422(message=ex.normalized_messages())
+        except AnnotationUpdateFailedError as ex:
+            logger.error(
+                "Error updating annotation %s: %s", self.__class__.__name__, 
str(ex)
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<int:pk>/annotation/<int:annotation_id>", methods=["DELETE"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("delete")
+    def delete(self, pk: int, annotation_id: int) -> Response:
+        """Deletes an Annotation
+        ---
+        delete:
+          description: >-
+            Delete an annotation
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+            description: The annotation layer pk for this annotation
+          - in: path
+            schema:
+              type: integer
+            name: annotation_id
+            description: The annotation pk for this annotation
+          responses:
+            200:
+              description: Item deleted
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            DeleteAnnotationCommand(g.user, annotation_id).run()
+            return self.response(200, message="OK")
+        except AnnotationNotFoundError as ex:
+            return self.response_404()
+        except AnnotationDeleteFailedError as ex:
+            logger.error(
+                "Error deleting annotation %s: %s", self.__class__.__name__, 
str(ex)
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<int:pk>/annotation/", methods=["DELETE"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @rison(get_delete_ids_schema)
+    def bulk_delete(self, **kwargs: Any) -> Response:
+        """Delete bulk Annotation layers
+        ---
+        delete:
+          description: >-
+            Deletes multiple annotation in a bulk operation.
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+            description: The annotation layer pk for this annotation
+          - in: query
+            name: q
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/get_delete_ids_schema'
+          responses:
+            200:
+              description: Annotations 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:
+            BulkDeleteAnnotationCommand(g.user, item_ids).run()
+            return self.response(
+                200,
+                message=ngettext(
+                    "Deleted %(num)d annotation",
+                    "Deleted %(num)d annotations",
+                    num=len(item_ids),
+                ),
+            )
+        except AnnotationNotFoundError:
+            return self.response_404()
+        except AnnotationBulkDeleteFailedError as ex:
+            return self.response_422(message=str(ex))
diff --git a/superset/annotation_layers/annotations/commands/__init__.py 
b/superset/annotation_layers/annotations/commands/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/superset/annotation_layers/annotations/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/annotation_layers/annotations/commands/bulk_delete.py 
b/superset/annotation_layers/annotations/commands/bulk_delete.py
new file mode 100644
index 0000000..9d9df6f
--- /dev/null
+++ b/superset/annotation_layers/annotations/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.annotation_layers.annotations.commands.exceptions import (
+    AnnotationBulkDeleteFailedError,
+    AnnotationNotFoundError,
+)
+from superset.annotation_layers.annotations.dao import AnnotationDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAODeleteFailedError
+from superset.models.annotations import Annotation
+
+logger = logging.getLogger(__name__)
+
+
+class BulkDeleteAnnotationCommand(BaseCommand):
+    def __init__(self, user: User, model_ids: List[int]):
+        self._actor = user
+        self._model_ids = model_ids
+        self._models: Optional[List[Annotation]] = None
+
+    def run(self) -> None:
+        self.validate()
+        try:
+            AnnotationDAO.bulk_delete(self._models)
+            return None
+        except DAODeleteFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationBulkDeleteFailedError()
+
+    def validate(self) -> None:
+        # Validate/populate model exists
+        self._models = AnnotationDAO.find_by_ids(self._model_ids)
+        if not self._models or len(self._models) != len(self._model_ids):
+            raise AnnotationNotFoundError()
diff --git a/superset/annotation_layers/annotations/commands/create.py 
b/superset/annotation_layers/annotations/commands/create.py
new file mode 100644
index 0000000..398e1b4
--- /dev/null
+++ b/superset/annotation_layers/annotations/commands/create.py
@@ -0,0 +1,80 @@
+# 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 datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from flask_appbuilder.models.sqla import Model
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset.annotation_layers.annotations.commands.exceptions import (
+    AnnotationCreateFailedError,
+    AnnotationDatesValidationError,
+    AnnotationInvalidError,
+    AnnotationUniquenessValidationError,
+)
+from superset.annotation_layers.annotations.dao import AnnotationDAO
+from superset.annotation_layers.commands.exceptions import 
AnnotationLayerNotFoundError
+from superset.annotation_layers.dao import AnnotationLayerDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAOCreateFailedError
+
+logger = logging.getLogger(__name__)
+
+
+class CreateAnnotationCommand(BaseCommand):
+    def __init__(self, user: User, data: Dict[str, Any]):
+        self._actor = user
+        self._properties = data.copy()
+
+    def run(self) -> Model:
+        self.validate()
+        try:
+            annotation = AnnotationDAO.create(self._properties)
+        except DAOCreateFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationCreateFailedError()
+        return annotation
+
+    def validate(self) -> None:
+        exceptions: List[ValidationError] = list()
+        layer_id: Optional[int] = self._properties.get("layer")
+        start_dttm: Optional[datetime] = self._properties.get("start_dttm")
+        end_dttm: Optional[datetime] = self._properties.get("end_dttm")
+        short_descr = self._properties.get("short_descr", "")
+
+        # Validate/populate model exists
+        if not layer_id and not isinstance(layer_id, int):
+            raise AnnotationLayerNotFoundError()
+        annotation_layer = AnnotationLayerDAO.find_by_id(layer_id)
+        if not annotation_layer:
+            raise AnnotationLayerNotFoundError()
+        self._properties["layer"] = annotation_layer
+
+        # Validate short descr uniqueness on this layer
+        if not AnnotationDAO.validate_update_uniqueness(layer_id, short_descr):
+            exceptions.append(AnnotationUniquenessValidationError())
+
+        # validate date time sanity
+        if start_dttm and end_dttm and end_dttm < start_dttm:
+            exceptions.append(AnnotationDatesValidationError)
+
+        if exceptions:
+            exception = AnnotationInvalidError()
+            exception.add_list(exceptions)
+            raise exception
diff --git a/superset/annotation_layers/annotations/commands/delete.py 
b/superset/annotation_layers/annotations/commands/delete.py
new file mode 100644
index 0000000..bd5c998
--- /dev/null
+++ b/superset/annotation_layers/annotations/commands/delete.py
@@ -0,0 +1,54 @@
+# 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 Optional
+
+from flask_appbuilder.models.sqla import Model
+from flask_appbuilder.security.sqla.models import User
+
+from superset.annotation_layers.annotations.commands.exceptions import (
+    AnnotationDeleteFailedError,
+    AnnotationNotFoundError,
+)
+from superset.annotation_layers.annotations.dao import AnnotationDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAODeleteFailedError
+from superset.models.annotations import Annotation
+
+logger = logging.getLogger(__name__)
+
+
+class DeleteAnnotationCommand(BaseCommand):
+    def __init__(self, user: User, model_id: int):
+        self._actor = user
+        self._model_id = model_id
+        self._model: Optional[Annotation] = None
+
+    def run(self) -> Model:
+        self.validate()
+        try:
+            annotation = AnnotationDAO.delete(self._model)
+        except DAODeleteFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationDeleteFailedError()
+        return annotation
+
+    def validate(self) -> None:
+        # Validate/populate model exists
+        self._model = AnnotationDAO.find_by_id(self._model_id)
+        if not self._model:
+            raise AnnotationNotFoundError()
diff --git a/superset/annotation_layers/annotations/commands/exceptions.py 
b/superset/annotation_layers/annotations/commands/exceptions.py
new file mode 100644
index 0000000..e7fc93e
--- /dev/null
+++ b/superset/annotation_layers/annotations/commands/exceptions.py
@@ -0,0 +1,72 @@
+# 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 marshmallow import ValidationError
+
+from superset.commands.exceptions import (
+    CommandException,
+    CommandInvalidError,
+    CreateFailedError,
+    DeleteFailedError,
+)
+
+
+class AnnotationDatesValidationError(ValidationError):
+    """
+    Marshmallow validation error for start date is after end date
+    """
+
+    def __init__(self) -> None:
+        super().__init__(
+            [_("End date must be after start date")], field_name="start_dttm"
+        )
+
+
+class AnnotationUniquenessValidationError(ValidationError):
+    """
+    Marshmallow validation error for annotation layer name already exists
+    """
+
+    def __init__(self) -> None:
+        super().__init__(
+            [_("Short description must be unique for this layer")],
+            field_name="short_descr",
+        )
+
+
+class AnnotationBulkDeleteFailedError(DeleteFailedError):
+    message = _("Annotations could not be deleted.")
+
+
+class AnnotationNotFoundError(CommandException):
+    message = _("Annotation not found.")
+
+
+class AnnotationInvalidError(CommandInvalidError):
+    message = _("Annotation parameters are invalid.")
+
+
+class AnnotationCreateFailedError(CreateFailedError):
+    message = _("Annotation could not be created.")
+
+
+class AnnotationUpdateFailedError(CreateFailedError):
+    message = _("Annotation could not be updated.")
+
+
+class AnnotationDeleteFailedError(CommandException):
+    message = _("Annotation delete failed.")
diff --git a/superset/annotation_layers/annotations/commands/update.py 
b/superset/annotation_layers/annotations/commands/update.py
new file mode 100644
index 0000000..2e6d568
--- /dev/null
+++ b/superset/annotation_layers/annotations/commands/update.py
@@ -0,0 +1,92 @@
+# 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 datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from flask_appbuilder.models.sqla import Model
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset.annotation_layers.annotations.commands.exceptions import (
+    AnnotationDatesValidationError,
+    AnnotationInvalidError,
+    AnnotationNotFoundError,
+    AnnotationUniquenessValidationError,
+    AnnotationUpdateFailedError,
+)
+from superset.annotation_layers.annotations.dao import AnnotationDAO
+from superset.annotation_layers.commands.exceptions import 
AnnotationLayerNotFoundError
+from superset.annotation_layers.dao import AnnotationLayerDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAOUpdateFailedError
+from superset.models.annotations import Annotation
+
+logger = logging.getLogger(__name__)
+
+
+class UpdateAnnotationCommand(BaseCommand):
+    def __init__(self, user: User, model_id: int, data: Dict[str, Any]):
+        self._actor = user
+        self._model_id = model_id
+        self._properties = data.copy()
+        self._model: Optional[Annotation] = None
+
+    def run(self) -> Model:
+        self.validate()
+        try:
+            annotation = AnnotationDAO.update(self._model, self._properties)
+        except DAOUpdateFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationUpdateFailedError()
+        return annotation
+
+    def validate(self) -> None:
+        exceptions: List[ValidationError] = list()
+        layer_id: Optional[int] = self._properties.get("layer")
+        short_descr: str = self._properties.get("short_descr", "")
+
+        # Validate/populate model exists
+        self._model = AnnotationDAO.find_by_id(self._model_id)
+        if not self._model:
+            raise AnnotationNotFoundError()
+        # Validate/populate layer exists
+        if layer_id:
+            annotation_layer = AnnotationLayerDAO.find_by_id(layer_id)
+            if not annotation_layer:
+                raise AnnotationLayerNotFoundError()
+            self._properties["layer"] = annotation_layer
+        else:
+            self._properties["layer"] = self._model.layer
+
+        # Validate short descr uniqueness on this layer
+        if not AnnotationDAO.validate_update_uniqueness(
+            layer_id, short_descr, annotation_id=self._model_id,
+        ):
+            exceptions.append(AnnotationUniquenessValidationError())
+
+        # validate date time sanity
+        start_dttm: Optional[datetime] = self._properties.get("start_dttm")
+        end_dttm: Optional[datetime] = self._properties.get("end_dttm")
+
+        if start_dttm and end_dttm and end_dttm < start_dttm:
+            exceptions.append(AnnotationDatesValidationError())
+
+        if exceptions:
+            exception = AnnotationInvalidError()
+            exception.add_list(exceptions)
+            raise exception
diff --git a/superset/annotation_layers/annotations/dao.py 
b/superset/annotation_layers/annotations/dao.py
new file mode 100644
index 0000000..293bc24
--- /dev/null
+++ b/superset/annotation_layers/annotations/dao.py
@@ -0,0 +1,65 @@
+# 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.annotations import Annotation
+
+logger = logging.getLogger(__name__)
+
+
+class AnnotationDAO(BaseDAO):
+    model_cls = Annotation
+
+    @staticmethod
+    def bulk_delete(models: Optional[List[Annotation]], commit: bool = True) 
-> None:
+        item_ids = [model.id for model in models] if models else []
+        try:
+            
db.session.query(Annotation).filter(Annotation.id.in_(item_ids)).delete(
+                synchronize_session="fetch"
+            )
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError:
+            if commit:
+                db.session.rollback()
+            raise DAODeleteFailedError()
+
+    @staticmethod
+    def validate_update_uniqueness(
+        layer_id: int, short_descr: str, annotation_id: Optional[int] = None
+    ):
+        """
+        Validate if this annotation short description is unique. `id` is 
optional
+        and serves for validating on updates
+
+        :param short_descr: The annotation short description
+        :param layer_id: The annotation layer current id
+        :param annotation_id: This annotation is (only for validating on 
updates)
+        :return: bool
+        """
+        query = db.session.query(Annotation).filter(
+            Annotation.short_descr == short_descr, Annotation.layer_id == 
layer_id
+        )
+        if annotation_id:
+            query = query.filter(Annotation.id != annotation_id)
+        return not db.session.query(query.exists()).scalar()
diff --git a/superset/annotation_layers/annotations/filters.py 
b/superset/annotation_layers/annotations/filters.py
new file mode 100644
index 0000000..2d917a4
--- /dev/null
+++ b/superset/annotation_layers/annotations/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.annotations import Annotation
+from superset.views.base import BaseFilter
+
+
+class AnnotationAllTextFilter(BaseFilter):  # pylint: 
disable=too-few-public-methods
+    name = _("All Text")
+    arg_name = "annotation_all_text"
+
+    def apply(self, query: Query, value: Any) -> Query:
+        if not value:
+            return query
+        ilike_value = f"%{value}%"
+        return query.filter(
+            or_(
+                Annotation.short_descr.ilike(ilike_value),
+                Annotation.long_descr.ilike(ilike_value),
+            )
+        )
diff --git a/superset/annotation_layers/annotations/schemas.py 
b/superset/annotation_layers/annotations/schemas.py
new file mode 100644
index 0000000..2ae0b91
--- /dev/null
+++ b/superset/annotation_layers/annotations/schemas.py
@@ -0,0 +1,80 @@
+# 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 json
+from typing import Union
+
+from marshmallow import fields, Schema, ValidationError
+from marshmallow.validate import Length
+
+from superset.exceptions import SupersetException
+from superset.utils import core as utils
+
+openapi_spec_methods_override = {
+    "get": {"get": {"description": "Get an Annotation layer"}},
+    "get_list": {
+        "get": {
+            "description": "Get a list of Annotation layers, use Rison or JSON 
"
+            "query parameters for filtering, sorting,"
+            " pagination and for selecting specific"
+            " columns and metadata.",
+        }
+    },
+    "post": {"post": {"description": "Create an Annotation layer"}},
+    "put": {"put": {"description": "Update an Annotation layer"}},
+    "delete": {"delete": {"description": "Delete Annotation layer"}},
+}
+
+get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
+
+
+annotation_start_dttm = "The annotation start date time"
+annotation_end_dttm = "The annotation end date time"
+annotation_layer = "The annotation layer id"
+annotation_short_descr = "A short description"
+annotation_long_descr = "A long description"
+annotation_json_metadata = "JSON metadata"
+
+
+def validate_json(value: Union[bytes, bytearray, str]) -> None:
+    try:
+        utils.validate_json(value)
+    except SupersetException:
+        raise ValidationError("JSON not valid")
+
+
+class AnnotationPostSchema(Schema):
+    short_descr = fields.String(
+        description=annotation_short_descr, allow_none=False, 
validate=[Length(1, 500)]
+    )
+    long_descr = fields.String(description=annotation_long_descr, 
allow_none=True)
+    start_dttm = fields.DateTime(description=annotation_start_dttm, 
allow_none=False)
+    end_dttm = fields.DateTime(description=annotation_end_dttm, 
allow_none=False)
+    json_metadata = fields.String(
+        description=annotation_json_metadata, validate=validate_json, 
allow_none=True,
+    )
+
+
+class AnnotationPutSchema(Schema):
+    short_descr = fields.String(
+        description=annotation_short_descr, required=False, 
validate=[Length(1, 500)]
+    )
+    long_descr = fields.String(description=annotation_long_descr, 
required=False)
+    start_dttm = fields.DateTime(description=annotation_start_dttm, 
required=False)
+    end_dttm = fields.DateTime(description=annotation_end_dttm, required=False)
+    json_metadata = fields.String(
+        description=annotation_json_metadata, validate=validate_json, 
required=False
+    )
diff --git a/superset/annotation_layers/api.py 
b/superset/annotation_layers/api.py
new file mode 100644
index 0000000..d6f07e5
--- /dev/null
+++ b/superset/annotation_layers/api.py
@@ -0,0 +1,327 @@
+# 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, request, Response
+from flask_appbuilder.api import expose, permission_name, protect, rison, safe
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_babel import ngettext
+from marshmallow import ValidationError
+
+from superset.annotation_layers.commands.bulk_delete import (
+    BulkDeleteAnnotationLayerCommand,
+)
+from superset.annotation_layers.commands.create import 
CreateAnnotationLayerCommand
+from superset.annotation_layers.commands.delete import 
DeleteAnnotationLayerCommand
+from superset.annotation_layers.commands.exceptions import (
+    AnnotationLayerBulkDeleteFailedError,
+    AnnotationLayerBulkDeleteIntegrityError,
+    AnnotationLayerCreateFailedError,
+    AnnotationLayerDeleteFailedError,
+    AnnotationLayerDeleteIntegrityError,
+    AnnotationLayerInvalidError,
+    AnnotationLayerNotFoundError,
+    AnnotationLayerUpdateFailedError,
+)
+from superset.annotation_layers.commands.update import 
UpdateAnnotationLayerCommand
+from superset.annotation_layers.filters import AnnotationLayerAllTextFilter
+from superset.annotation_layers.schemas import (
+    AnnotationLayerPostSchema,
+    AnnotationLayerPutSchema,
+    get_delete_ids_schema,
+    openapi_spec_methods_override,
+)
+from superset.constants import RouteMethod
+from superset.models.annotations import AnnotationLayer
+from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics
+
+logger = logging.getLogger(__name__)
+
+
+class AnnotationLayerRestApi(BaseSupersetModelRestApi):
+    datamodel = SQLAInterface(AnnotationLayer)
+
+    include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
+        "bulk_delete",  # not using RouteMethod since locally defined
+    }
+    class_permission_name = "AnnotationLayerModelView"
+    resource_name = "annotation_layer"
+    allow_browser_login = True
+
+    show_columns = [
+        "name",
+        "descr",
+    ]
+    list_columns = [
+        "name",
+        "descr",
+        "created_by.first_name",
+        "created_by.last_name",
+        "changed_by.first_name",
+        "changed_by.last_name",
+        "changed_on_delta_humanized",
+    ]
+    add_columns = ["name", "descr"]
+    edit_columns = add_columns
+    add_model_schema = AnnotationLayerPostSchema()
+    edit_model_schema = AnnotationLayerPutSchema()
+
+    order_columns = [
+        "name",
+        "descr",
+        "created_by.first_name",
+        "changed_by.first_name",
+        "changed_on_delta_humanized",
+    ]
+
+    search_filters = {"name": [AnnotationLayerAllTextFilter]}
+
+    apispec_parameter_schemas = {
+        "get_delete_ids_schema": get_delete_ids_schema,
+    }
+    openapi_spec_tag = "Annotation Layers"
+    openapi_spec_methods = openapi_spec_methods_override
+
+    @expose("/<int:pk>", methods=["DELETE"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("delete")
+    def delete(self, pk: int) -> Response:
+        """Delete an annotation layer
+        ---
+        delete:
+          description: >-
+            Delete an annotation layer
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+            description: The annotation layer pk for this annotation
+          responses:
+            200:
+              description: Item deleted
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      message:
+                        type: string
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        try:
+            DeleteAnnotationLayerCommand(g.user, pk).run()
+            return self.response(200, message="OK")
+        except AnnotationLayerNotFoundError as ex:
+            return self.response_404()
+        except AnnotationLayerDeleteIntegrityError as ex:
+            return self.response_422(message=str(ex))
+        except AnnotationLayerDeleteFailedError as ex:
+            logger.error(
+                "Error deleting annotation layer %s: %s",
+                self.__class__.__name__,
+                str(ex),
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/", methods=["POST"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("post")
+    def post(self) -> Response:
+        """Creates a new Annotation Layer
+        ---
+        post:
+          description: >-
+            Create a new Annotation
+          requestBody:
+            description: Annotation Layer schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.post'
+          responses:
+            201:
+              description: Annotation added
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: 
'#/components/schemas/{{self.__class__.__name__}}.post'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        try:
+            item = self.add_model_schema.load(request.json)
+        # This validates custom Schema with custom validations
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+        try:
+            new_model = CreateAnnotationLayerCommand(g.user, item).run()
+            return self.response(201, id=new_model.id, result=item)
+        except AnnotationLayerNotFoundError as ex:
+            return self.response_400(message=str(ex))
+        except AnnotationLayerInvalidError as ex:
+            return self.response_422(message=ex.normalized_messages())
+        except AnnotationLayerCreateFailedError as ex:
+            logger.error(
+                "Error creating annotation %s: %s", self.__class__.__name__, 
str(ex)
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/<int:pk>", methods=["PUT"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("put")
+    def put(self, pk: int) -> Response:
+        """Updates an Annotation Layer
+        ---
+        put:
+          description: >-
+            Update an annotation layer
+          parameters:
+          - in: path
+            schema:
+              type: integer
+            name: pk
+            description: The annotation layer pk for this annotation
+          requestBody:
+            description: Annotation schema
+            required: true
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/{{self.__class__.__name__}}.put'
+          responses:
+            200:
+              description: Annotation changed
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      id:
+                        type: number
+                      result:
+                        $ref: 
'#/components/schemas/{{self.__class__.__name__}}.put'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            500:
+              $ref: '#/components/responses/500'
+        """
+        if not request.is_json:
+            return self.response_400(message="Request is not JSON")
+        try:
+            item = self.edit_model_schema.load(request.json)
+            item["layer"] = pk
+        # This validates custom Schema with custom validations
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+        try:
+            new_model = UpdateAnnotationLayerCommand(g.user, pk, item).run()
+            return self.response(200, id=new_model.id, result=item)
+        except (AnnotationLayerNotFoundError) as ex:
+            return self.response_404()
+        except AnnotationLayerInvalidError as ex:
+            return self.response_422(message=ex.normalized_messages())
+        except AnnotationLayerUpdateFailedError as ex:
+            logger.error(
+                "Error updating annotation %s: %s", self.__class__.__name__, 
str(ex)
+            )
+            return self.response_422(message=str(ex))
+
+    @expose("/", methods=["DELETE"])
+    @protect()
+    @safe
+    @statsd_metrics
+    @rison(get_delete_ids_schema)
+    def bulk_delete(self, **kwargs: Any) -> Response:
+        """Delete bulk Annotation layers
+        ---
+        delete:
+          description: >-
+            Deletes multiple annotation layers 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:
+            BulkDeleteAnnotationLayerCommand(g.user, item_ids).run()
+            return self.response(
+                200,
+                message=ngettext(
+                    "Deleted %(num)d annotation layer",
+                    "Deleted %(num)d annotation layers",
+                    num=len(item_ids),
+                ),
+            )
+        except AnnotationLayerNotFoundError:
+            return self.response_404()
+        except AnnotationLayerBulkDeleteIntegrityError as ex:
+            return self.response_422(message=str(ex))
+        except AnnotationLayerBulkDeleteFailedError as ex:
+            return self.response_422(message=str(ex))
diff --git a/superset/annotation_layers/commands/__init__.py 
b/superset/annotation_layers/commands/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/superset/annotation_layers/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/annotation_layers/commands/bulk_delete.py 
b/superset/annotation_layers/commands/bulk_delete.py
new file mode 100644
index 0000000..148bdd0
--- /dev/null
+++ b/superset/annotation_layers/commands/bulk_delete.py
@@ -0,0 +1,56 @@
+# 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.annotation_layers.commands.exceptions import (
+    AnnotationLayerBulkDeleteFailedError,
+    AnnotationLayerBulkDeleteIntegrityError,
+    AnnotationLayerNotFoundError,
+)
+from superset.annotation_layers.dao import AnnotationLayerDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAODeleteFailedError
+from superset.models.annotations import AnnotationLayer
+
+logger = logging.getLogger(__name__)
+
+
+class BulkDeleteAnnotationLayerCommand(BaseCommand):
+    def __init__(self, user: User, model_ids: List[int]):
+        self._actor = user
+        self._model_ids = model_ids
+        self._models: Optional[List[AnnotationLayer]] = None
+
+    def run(self) -> None:
+        self.validate()
+        try:
+            AnnotationLayerDAO.bulk_delete(self._models)
+            return None
+        except DAODeleteFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationLayerBulkDeleteFailedError()
+
+    def validate(self) -> None:
+        # Validate/populate model exists
+        self._models = AnnotationLayerDAO.find_by_ids(self._model_ids)
+        if not self._models or len(self._models) != len(self._model_ids):
+            raise AnnotationLayerNotFoundError()
+        if AnnotationLayerDAO.has_annotations(self._model_ids):
+            raise AnnotationLayerBulkDeleteIntegrityError()
diff --git a/superset/annotation_layers/commands/create.py 
b/superset/annotation_layers/commands/create.py
new file mode 100644
index 0000000..c625921
--- /dev/null
+++ b/superset/annotation_layers/commands/create.py
@@ -0,0 +1,61 @@
+# 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, Dict, List
+
+from flask_appbuilder.models.sqla import Model
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset.annotation_layers.commands.exceptions import (
+    AnnotationLayerCreateFailedError,
+    AnnotationLayerInvalidError,
+    AnnotationLayerNameUniquenessValidationError,
+)
+from superset.annotation_layers.dao import AnnotationLayerDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAOCreateFailedError
+
+logger = logging.getLogger(__name__)
+
+
+class CreateAnnotationLayerCommand(BaseCommand):
+    def __init__(self, user: User, data: Dict[str, Any]):
+        self._actor = user
+        self._properties = data.copy()
+
+    def run(self) -> Model:
+        self.validate()
+        try:
+            annotation_layer = AnnotationLayerDAO.create(self._properties)
+        except DAOCreateFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationLayerCreateFailedError()
+        return annotation_layer
+
+    def validate(self) -> None:
+        exceptions: List[ValidationError] = list()
+
+        name = self._properties.get("name", "")
+
+        if not AnnotationLayerDAO.validate_update_uniqueness(name):
+            exceptions.append(AnnotationLayerNameUniquenessValidationError())
+
+        if exceptions:
+            exception = AnnotationLayerInvalidError()
+            exception.add_list(exceptions)
+            raise exception
diff --git a/superset/annotation_layers/commands/delete.py 
b/superset/annotation_layers/commands/delete.py
new file mode 100644
index 0000000..dd9b2c4
--- /dev/null
+++ b/superset/annotation_layers/commands/delete.py
@@ -0,0 +1,57 @@
+# 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 Optional
+
+from flask_appbuilder.models.sqla import Model
+from flask_appbuilder.security.sqla.models import User
+
+from superset.annotation_layers.commands.exceptions import (
+    AnnotationLayerDeleteFailedError,
+    AnnotationLayerDeleteIntegrityError,
+    AnnotationLayerNotFoundError,
+)
+from superset.annotation_layers.dao import AnnotationLayerDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAODeleteFailedError
+from superset.models.annotations import AnnotationLayer
+
+logger = logging.getLogger(__name__)
+
+
+class DeleteAnnotationLayerCommand(BaseCommand):
+    def __init__(self, user: User, model_id: int):
+        self._actor = user
+        self._model_id = model_id
+        self._model: Optional[AnnotationLayer] = None
+
+    def run(self) -> Model:
+        self.validate()
+        try:
+            annotation_layer = AnnotationLayerDAO.delete(self._model)
+        except DAODeleteFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationLayerDeleteFailedError()
+        return annotation_layer
+
+    def validate(self) -> None:
+        # Validate/populate model exists
+        self._model = AnnotationLayerDAO.find_by_id(self._model_id)
+        if not self._model:
+            raise AnnotationLayerNotFoundError()
+        if AnnotationLayerDAO.has_annotations(self._model.id):
+            raise AnnotationLayerDeleteIntegrityError()
diff --git a/superset/annotation_layers/commands/exceptions.py 
b/superset/annotation_layers/commands/exceptions.py
new file mode 100644
index 0000000..5849623
--- /dev/null
+++ b/superset/annotation_layers/commands/exceptions.py
@@ -0,0 +1,66 @@
+# 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,
+    CommandInvalidError,
+    CreateFailedError,
+    DeleteFailedError,
+    ValidationError,
+)
+
+
+class AnnotationLayerInvalidError(CommandInvalidError):
+    message = _("Annotation layer parameters are invalid.")
+
+
+class AnnotationLayerBulkDeleteFailedError(DeleteFailedError):
+    message = _("Annotation layer could not be deleted.")
+
+
+class AnnotationLayerCreateFailedError(CreateFailedError):
+    message = _("Annotation layer could not be created.")
+
+
+class AnnotationLayerUpdateFailedError(CreateFailedError):
+    message = _("Annotation layer could not be updated.")
+
+
+class AnnotationLayerNotFoundError(CommandException):
+    message = _("Annotation layer not found.")
+
+
+class AnnotationLayerDeleteFailedError(CommandException):
+    message = _("Annotation layer delete failed.")
+
+
+class AnnotationLayerDeleteIntegrityError(CommandException):
+    message = _("Annotation layer has associated annotations.")
+
+
+class AnnotationLayerBulkDeleteIntegrityError(CommandException):
+    message = _("Annotation layer has associated annotations.")
+
+
+class AnnotationLayerNameUniquenessValidationError(ValidationError):
+    """
+    Marshmallow validation error for annotation layer name already exists
+    """
+
+    def __init__(self) -> None:
+        super().__init__([_("Name must be unique")], field_name="name")
diff --git a/superset/annotation_layers/commands/update.py 
b/superset/annotation_layers/commands/update.py
new file mode 100644
index 0000000..d5625cc
--- /dev/null
+++ b/superset/annotation_layers/commands/update.py
@@ -0,0 +1,70 @@
+# 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, Dict, List, Optional
+
+from flask_appbuilder.models.sqla import Model
+from flask_appbuilder.security.sqla.models import User
+from marshmallow import ValidationError
+
+from superset.annotation_layers.commands.exceptions import (
+    AnnotationLayerInvalidError,
+    AnnotationLayerNameUniquenessValidationError,
+    AnnotationLayerNotFoundError,
+    AnnotationLayerUpdateFailedError,
+)
+from superset.annotation_layers.dao import AnnotationLayerDAO
+from superset.commands.base import BaseCommand
+from superset.dao.exceptions import DAOUpdateFailedError
+from superset.models.annotations import AnnotationLayer
+
+logger = logging.getLogger(__name__)
+
+
+class UpdateAnnotationLayerCommand(BaseCommand):
+    def __init__(self, user: User, model_id: int, data: Dict[str, Any]):
+        self._actor = user
+        self._model_id = model_id
+        self._properties = data.copy()
+        self._model: Optional[AnnotationLayer] = None
+
+    def run(self) -> Model:
+        self.validate()
+        try:
+            annotation_layer = AnnotationLayerDAO.update(self._model, 
self._properties)
+        except DAOUpdateFailedError as ex:
+            logger.exception(ex.exception)
+            raise AnnotationLayerUpdateFailedError()
+        return annotation_layer
+
+    def validate(self) -> None:
+        exceptions: List[ValidationError] = list()
+        name = self._properties.get("name", "")
+        self._model = AnnotationLayerDAO.find_by_id(self._model_id)
+
+        if not self._model:
+            raise AnnotationLayerNotFoundError()
+
+        if not AnnotationLayerDAO.validate_update_uniqueness(
+            name, layer_id=self._model_id
+        ):
+            exceptions.append(AnnotationLayerNameUniquenessValidationError())
+
+        if exceptions:
+            exception = AnnotationLayerInvalidError()
+            exception.add_list(exceptions)
+            raise exception
diff --git a/superset/annotation_layers/dao.py 
b/superset/annotation_layers/dao.py
new file mode 100644
index 0000000..9c848e5
--- /dev/null
+++ b/superset/annotation_layers/dao.py
@@ -0,0 +1,79 @@
+# 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, Union
+
+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.annotations import Annotation, AnnotationLayer
+
+logger = logging.getLogger(__name__)
+
+
+class AnnotationLayerDAO(BaseDAO):
+    model_cls = AnnotationLayer
+
+    @staticmethod
+    def bulk_delete(
+        models: Optional[List[AnnotationLayer]], commit: bool = True
+    ) -> None:
+        item_ids = [model.id for model in models] if models else []
+        try:
+            db.session.query(AnnotationLayer).filter(
+                AnnotationLayer.id.in_(item_ids)
+            ).delete(synchronize_session="fetch")
+            if commit:
+                db.session.commit()
+        except SQLAlchemyError:
+            if commit:
+                db.session.rollback()
+            raise DAODeleteFailedError()
+
+    @staticmethod
+    def has_annotations(model_id: Union[int, List[int]]) -> bool:
+        if isinstance(model_id, list):
+            return (
+                db.session.query(AnnotationLayer)
+                .filter(AnnotationLayer.id.in_(model_id))
+                .join(Annotation)
+                .first()
+            ) is not None
+        return (
+            db.session.query(AnnotationLayer)
+            .filter(AnnotationLayer.id == model_id)
+            .join(Annotation)
+            .first()
+        ) is not None
+
+    @staticmethod
+    def validate_update_uniqueness(name: str, layer_id: Optional[int] = None) 
-> bool:
+        """
+        Validate if this layer name is unique. `layer_id` is optional
+        and serves for validating on updates
+
+        :param name: The annotation layer name
+        :param layer_id: The annotation layer current id
+        (only for validating on updates)
+        :return: bool
+        """
+        query = db.session.query(AnnotationLayer).filter(AnnotationLayer.name 
== name)
+        if layer_id:
+            query = query.filter(AnnotationLayer.id != layer_id)
+        return not db.session.query(query.exists()).scalar()
diff --git a/superset/annotation_layers/filters.py 
b/superset/annotation_layers/filters.py
new file mode 100644
index 0000000..5fbf13b
--- /dev/null
+++ b/superset/annotation_layers/filters.py
@@ -0,0 +1,42 @@
+# 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.annotations import AnnotationLayer
+from superset.views.base import BaseFilter
+
+
+class AnnotationLayerAllTextFilter(
+    BaseFilter
+):  # pylint: disable=too-few-public-methods
+    name = _("All Text")
+    arg_name = "annotation_layer_all_text"
+
+    def apply(self, query: Query, value: Any) -> Query:
+        if not value:
+            return query
+        ilike_value = f"%{value}%"
+        return query.filter(
+            or_(
+                AnnotationLayer.name.ilike(ilike_value),
+                AnnotationLayer.descr.ilike(ilike_value),
+            )
+        )
diff --git a/superset/annotation_layers/schemas.py 
b/superset/annotation_layers/schemas.py
new file mode 100644
index 0000000..4e7c6de
--- /dev/null
+++ b/superset/annotation_layers/schemas.py
@@ -0,0 +1,52 @@
+# 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 marshmallow import fields, Schema
+from marshmallow.validate import Length
+
+openapi_spec_methods_override = {
+    "get": {"get": {"description": "Get an Annotation layer"}},
+    "get_list": {
+        "get": {
+            "description": "Get a list of Annotation layers, use Rison or JSON 
"
+            "query parameters for filtering, sorting,"
+            " pagination and for selecting specific"
+            " columns and metadata.",
+        }
+    },
+    "post": {"post": {"description": "Create an Annotation layer"}},
+    "put": {"put": {"description": "Update an Annotation layer"}},
+    "delete": {"delete": {"description": "Delete Annotation layer"}},
+}
+
+get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
+
+annotation_layer_name = "The annotation layer name"
+annotation_layer_descr = "Give a description for this annotation layer"
+
+
+class AnnotationLayerPostSchema(Schema):
+    name = fields.String(
+        description=annotation_layer_name, allow_none=False, 
validate=[Length(1, 250)]
+    )
+    descr = fields.String(description=annotation_layer_descr, allow_none=True)
+
+
+class AnnotationLayerPutSchema(Schema):
+    name = fields.String(
+        description=annotation_layer_name, required=False, validate=[Length(1, 
250)]
+    )
+    descr = fields.String(description=annotation_layer_descr, required=False)
diff --git a/superset/app.py b/superset/app.py
index a3b620a..dc8d612 100644
--- a/superset/app.py
+++ b/superset/app.py
@@ -125,6 +125,8 @@ class SupersetAppInitializer:
         #
         # pylint: disable=too-many-locals
         # pylint: disable=too-many-statements
+        from superset.annotation_layers.api import AnnotationLayerRestApi
+        from superset.annotation_layers.annotations.api import 
AnnotationRestApi
         from superset.cachekeys.api import CacheRestApi
         from superset.charts.api import ChartRestApi
         from superset.connectors.druid.views import (
@@ -194,6 +196,8 @@ class SupersetAppInitializer:
         #
         # Setup API views
         #
+        appbuilder.add_api(AnnotationRestApi)
+        appbuilder.add_api(AnnotationLayerRestApi)
         appbuilder.add_api(CacheRestApi)
         appbuilder.add_api(ChartRestApi)
         appbuilder.add_api(CssTemplateRestApi)
diff --git a/superset/models/annotations.py b/superset/models/annotations.py
index b2672fb..3185460 100644
--- a/superset/models/annotations.py
+++ b/superset/models/annotations.py
@@ -63,3 +63,6 @@ class Annotation(Model, AuditMixinNullable):
             "long_descr": self.long_descr,
             "layer": self.layer.name if self.layer else None,
         }
+
+    def __repr__(self) -> str:
+        return str(self.short_descr)
diff --git a/tests/annotation_layers/__init__.py 
b/tests/annotation_layers/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/tests/annotation_layers/__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/annotation_layers/api_tests.py 
b/tests/annotation_layers/api_tests.py
new file mode 100644
index 0000000..22ddecd
--- /dev/null
+++ b/tests/annotation_layers/api_tests.py
@@ -0,0 +1,719 @@
+# 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"""
+from datetime import datetime
+from typing import Optional
+import json
+
+import pytest
+import prison
+from sqlalchemy.sql import func
+
+import tests.test_app
+from superset import db
+from superset.models.annotations import Annotation, AnnotationLayer
+
+from tests.base_tests import SupersetTestCase
+
+
+ANNOTATION_LAYERS_COUNT = 10
+ANNOTATIONS_COUNT = 5
+
+
+class TestAnnotationLayerApi(SupersetTestCase):
+    def insert_annotation_layer(
+        self, name: str = "", descr: str = ""
+    ) -> AnnotationLayer:
+        annotation_layer = AnnotationLayer(name=name, descr=descr,)
+        db.session.add(annotation_layer)
+        db.session.commit()
+        return annotation_layer
+
+    def insert_annotation(
+        self,
+        layer: AnnotationLayer,
+        short_descr: str,
+        long_descr: str,
+        json_metadata: Optional[str] = "",
+        start_dttm: Optional[datetime] = None,
+        end_dttm: Optional[datetime] = None,
+    ) -> Annotation:
+        annotation = Annotation(
+            layer=layer,
+            short_descr=short_descr,
+            long_descr=long_descr,
+            json_metadata=json_metadata,
+            start_dttm=start_dttm,
+            end_dttm=end_dttm,
+        )
+        db.session.add(annotation)
+        db.session.commit()
+        return annotation
+
+    @pytest.fixture()
+    def create_annotation_layers(self):
+        """
+        Creates ANNOTATION_LAYERS_COUNT-1 layers with no annotations
+        and a final one with ANNOTATION_COUNT childs
+        :return:
+        """
+        with self.create_app().app_context():
+            annotation_layers = []
+            annotations = []
+            for cx in range(ANNOTATION_LAYERS_COUNT - 1):
+                annotation_layers.append(
+                    self.insert_annotation_layer(name=f"name{cx}", 
descr=f"descr{cx}")
+                )
+            layer_with_annotations = self.insert_annotation_layer(
+                "layer_with_annotations"
+            )
+            annotation_layers.append(layer_with_annotations)
+            for cx in range(ANNOTATIONS_COUNT):
+                annotations.append(
+                    self.insert_annotation(
+                        layer_with_annotations,
+                        short_descr=f"short_descr{cx}",
+                        long_descr=f"long_descr{cx}",
+                    )
+                )
+            yield annotation_layers
+
+            # rollback changes
+            for annotation_layer in annotation_layers:
+                db.session.delete(annotation_layer)
+            for annotation in annotations:
+                db.session.delete(annotation)
+            db.session.commit()
+
+    @staticmethod
+    def get_layer_with_annotation() -> AnnotationLayer:
+        return (
+            db.session.query(AnnotationLayer)
+            .filter(AnnotationLayer.name == "layer_with_annotations")
+            .one_or_none()
+        )
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_annotation_layer(self):
+        """
+        Annotation Api: Test get annotation layer
+        """
+        annotation_layer = (
+            db.session.query(AnnotationLayer)
+            .filter(AnnotationLayer.name == "name1")
+            .first()
+        )
+
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{annotation_layer.id}"
+        rv = self.get_assert_metric(uri, "get")
+        assert rv.status_code == 200
+
+        expected_result = {
+            "name": "name1",
+            "descr": "descr1",
+        }
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["result"] == expected_result
+
+    def test_info_annotation(self):
+        """
+        Annotation API: Test info
+        """
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/_info"
+        rv = self.get_assert_metric(uri, "info")
+        assert rv.status_code == 200
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_annotation_layer_not_found(self):
+        """
+        Annotation Api: Test get annotation layer not found
+        """
+        max_id = db.session.query(func.max(AnnotationLayer.id)).scalar()
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{max_id + 1}"
+        rv = self.get_assert_metric(uri, "get")
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_list_annotation_layer(self):
+        """
+        Annotation Api: Test get list annotation layers
+        """
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/"
+        rv = self.get_assert_metric(uri, "get_list")
+
+        expected_fields = [
+            "name",
+            "descr",
+            "created_by",
+            "changed_by",
+            "changed_on_delta_humanized",
+        ]
+        assert rv.status_code == 200
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["count"] == ANNOTATION_LAYERS_COUNT
+        for expected_field in expected_fields:
+            assert expected_field in data["result"][0]
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_list_annotation_layer_sorting(self):
+        """
+        Annotation Api: Test sorting on get list annotation layers
+        """
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/"
+
+        order_columns = [
+            "name",
+            "descr",
+            "created_by.first_name",
+            "changed_by.first_name",
+            "changed_on_delta_humanized",
+        ]
+
+        for order_column in order_columns:
+            arguments = {"order_column": order_column, "order_direction": 
"asc"}
+            uri = f"api/v1/annotation_layer/?q={prison.dumps(arguments)}"
+            rv = self.get_assert_metric(uri, "get_list")
+            assert rv.status_code == 200
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_list_annotation_layer_filter(self):
+        """
+        Annotation Api: Test filters on get list annotation layers
+        """
+        self.login(username="admin")
+        arguments = {
+            "columns": ["name", "descr"],
+            "filters": [
+                {"col": "name", "opr": "annotation_layer_all_text", "value": 
"2"}
+            ],
+        }
+        uri = f"api/v1/annotation_layer/?q={prison.dumps(arguments)}"
+        rv = self.get_assert_metric(uri, "get_list")
+
+        expected_result = {
+            "name": "name2",
+            "descr": "descr2",
+        }
+        assert rv.status_code == 200
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["count"] == 1
+        assert data["result"][0] == expected_result
+
+        arguments = {
+            "columns": ["name", "descr"],
+            "filters": [
+                {"col": "name", "opr": "annotation_layer_all_text", "value": 
"descr3"}
+            ],
+        }
+        uri = f"api/v1/annotation_layer/?q={prison.dumps(arguments)}"
+        rv = self.get_assert_metric(uri, "get_list")
+
+        expected_result = {
+            "name": "name3",
+            "descr": "descr3",
+        }
+
+        assert rv.status_code == 200
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["count"] == 1
+        assert data["result"][0] == expected_result
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_create_annotation_layer(self):
+        """
+        Annotation Api: Test create annotation layer
+        """
+        self.login(username="admin")
+        annotation_layer_data = {"name": "new3", "descr": "description"}
+        uri = "api/v1/annotation_layer/"
+        rv = self.client.post(uri, json=annotation_layer_data)
+        assert rv.status_code == 201
+        data = json.loads(rv.data.decode("utf-8"))
+        created_model = db.session.query(AnnotationLayer).get(data.get("id"))
+        assert created_model is not None
+        assert created_model.name == annotation_layer_data["name"]
+        assert created_model.descr == annotation_layer_data["descr"]
+
+        # Rollback changes
+        db.session.delete(created_model)
+        db.session.commit()
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_create_annotation_layer_uniqueness(self):
+        """
+        Annotation Api: Test create annotation layer uniqueness
+        """
+        self.login(username="admin")
+        annotation_layer_data = {"name": "name3", "descr": "description"}
+        uri = "api/v1/annotation_layer/"
+        rv = self.client.post(uri, json=annotation_layer_data)
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data == {"message": {"name": ["Name must be unique"]}}
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_update_annotation_layer(self):
+        """
+        Annotation Api: Test update annotation layer
+        """
+        annotation_layer = (
+            db.session.query(AnnotationLayer)
+            .filter(AnnotationLayer.name == "name2")
+            .one_or_none()
+        )
+
+        self.login(username="admin")
+        annotation_layer_data = {"name": "changed_name", "descr": 
"changed_description"}
+        uri = f"api/v1/annotation_layer/{annotation_layer.id}"
+        rv = self.client.put(uri, json=annotation_layer_data)
+        assert rv.status_code == 200
+        updated_model = 
db.session.query(AnnotationLayer).get(annotation_layer.id)
+        assert updated_model is not None
+        assert updated_model.name == annotation_layer_data["name"]
+        assert updated_model.descr == annotation_layer_data["descr"]
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_update_annotation_layer_uniqueness(self):
+        """
+        Annotation Api: Test update annotation layer uniqueness
+        """
+        annotation_layer = (
+            db.session.query(AnnotationLayer)
+            .filter(AnnotationLayer.name == "name2")
+            .one_or_none()
+        )
+
+        self.login(username="admin")
+        annotation_layer_data = {"name": "name3", "descr": 
"changed_description"}
+        uri = f"api/v1/annotation_layer/{annotation_layer.id}"
+        rv = self.client.put(uri, json=annotation_layer_data)
+        data = json.loads(rv.data.decode("utf-8"))
+        assert rv.status_code == 422
+        assert data == {"message": {"name": ["Name must be unique"]}}
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_update_annotation_layer_not_found(self):
+        """
+        Annotation Api: Test update annotation layer not found
+        """
+        max_id = db.session.query(func.max(AnnotationLayer.id)).scalar()
+
+        self.login(username="admin")
+        annotation_layer_data = {"name": "changed_name", "descr": 
"changed_description"}
+        uri = f"api/v1/annotation_layer/{max_id + 1}"
+        rv = self.client.put(uri, json=annotation_layer_data)
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_delete_annotation_layer(self):
+        """
+        Annotation Api: Test update annotation layer
+        """
+        annotation_layer = (
+            db.session.query(AnnotationLayer)
+            .filter(AnnotationLayer.name == "name1")
+            .one_or_none()
+        )
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{annotation_layer.id}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 200
+        updated_model = 
db.session.query(AnnotationLayer).get(annotation_layer.id)
+        assert updated_model is None
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_delete_annotation_layer_not_found(self):
+        """
+        Annotation Api: Test delete annotation layer not found
+        """
+        max_id = db.session.query(func.max(AnnotationLayer.id)).scalar()
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{max_id + 1}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_delete_annotation_layer_integrity(self):
+        """
+        Annotation Api: Test delete annotation layer integrity error
+        """
+        query_child_layer = db.session.query(AnnotationLayer).filter(
+            AnnotationLayer.name == "layer_with_annotations"
+        )
+        child_layer = query_child_layer.one_or_none()
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{child_layer.id}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 422
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_bulk_delete_annotation_layer(self):
+        """
+        Annotation Api: Test bulk delete annotation layers
+        """
+        query_no_child_layers = db.session.query(AnnotationLayer).filter(
+            AnnotationLayer.name.like("name%")
+        )
+
+        no_child_layers = query_no_child_layers.all()
+        no_child_layers_ids = [
+            annotation_layer.id for annotation_layer in no_child_layers
+        ]
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/?q={prison.dumps(no_child_layers_ids)}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 200
+        deleted_annotation_layers = query_no_child_layers.all()
+        assert deleted_annotation_layers == []
+        response = json.loads(rv.data.decode("utf-8"))
+        expected_response = {
+            "message": f"Deleted {len(no_child_layers_ids)} annotation layers"
+        }
+        assert response == expected_response
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_bulk_delete_annotation_layer_not_found(self):
+        """
+        Annotation Api: Test bulk delete annotation layers not found
+        """
+        all_annotation_layers = (
+            db.session.query(AnnotationLayer)
+            .filter(AnnotationLayer.name.like("name%"))
+            .all()
+        )
+        all_annotation_layers_ids = [
+            annotation_layer.id for annotation_layer in all_annotation_layers
+        ]
+        max_id = db.session.query(func.max(AnnotationLayer.id)).scalar()
+        all_annotation_layers_ids.append(max_id + 1)
+        self.login(username="admin")
+        uri = 
f"api/v1/annotation_layer/?q={prison.dumps(all_annotation_layers_ids)}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_annotation(self):
+        """
+        Annotation API: Test get annotation
+        """
+        annotation = (
+            db.session.query(Annotation)
+            .filter(Annotation.short_descr == "short_descr1")
+            .one_or_none()
+        )
+
+        self.login(username="admin")
+        uri = (
+            
f"api/v1/annotation_layer/{annotation.layer_id}/annotation/{annotation.id}"
+        )
+        rv = self.get_assert_metric(uri, "get")
+        assert rv.status_code == 200
+
+        expected_result = {
+            "end_dttm": None,
+            "json_metadata": "",
+            "layer": {"id": annotation.layer_id, "name": 
"layer_with_annotations"},
+            "long_descr": annotation.long_descr,
+            "short_descr": annotation.short_descr,
+            "start_dttm": None,
+        }
+
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["result"] == expected_result
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_annotation_not_found(self):
+        """
+        Annotation API: Test get annotation not found
+        """
+        layer = self.get_layer_with_annotation()
+        max_id = db.session.query(func.max(Annotation.id)).scalar()
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/{max_id + 1}"
+        rv = self.get_assert_metric(uri, "get")
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_list_annotation(self):
+        """
+        Annotation Api: Test get list of annotations
+        """
+        layer = self.get_layer_with_annotation()
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/"
+        rv = self.get_assert_metric(uri, "get_list")
+
+        expected_fields = [
+            "short_descr",
+            "created_by",
+            "changed_by",
+            "start_dttm",
+            "end_dttm",
+        ]
+
+        assert rv.status_code == 200
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["count"] == ANNOTATIONS_COUNT
+        for expected_field in expected_fields:
+            assert expected_field in data["result"][0]
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_list_annotation_sorting(self):
+        """
+        Annotation Api: Test sorting on get list of annotations
+        """
+        layer = self.get_layer_with_annotation()
+        self.login(username="admin")
+
+        order_columns = [
+            "short_descr",
+            "created_by.first_name",
+            "changed_by.first_name",
+            "changed_on_delta_humanized",
+            "start_dttm",
+            "end_dttm",
+        ]
+        for order_column in order_columns:
+            arguments = {"order_column": order_column, "order_direction": 
"asc"}
+            uri = 
f"api/v1/annotation_layer/{layer.id}/annotation/?q={prison.dumps(arguments)}"
+            rv = self.get_assert_metric(uri, "get_list")
+            assert rv.status_code == 200
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_get_list_annotation_filter(self):
+        """
+        Annotation Api: Test filters on get list annotation layers
+        """
+        layer = self.get_layer_with_annotation()
+        self.login(username="admin")
+        arguments = {
+            "filters": [
+                {"col": "short_descr", "opr": "annotation_all_text", "value": 
"2"}
+            ]
+        }
+        uri = 
f"api/v1/annotation_layer/{layer.id}/annotation/?q={prison.dumps(arguments)}"
+        rv = self.get_assert_metric(uri, "get_list")
+
+        assert rv.status_code == 200
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["count"] == 1
+
+        arguments = {
+            "filters": [
+                {"col": "short_descr", "opr": "annotation_all_text", "value": 
"descr3"}
+            ]
+        }
+        uri = 
f"api/v1/annotation_layer/{layer.id}/annotation/?q={prison.dumps(arguments)}"
+        rv = self.get_assert_metric(uri, "get_list")
+
+        assert rv.status_code == 200
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data["count"] == 1
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_create_annotation(self):
+        """
+        Annotation Api: Test create annotation
+        """
+        layer = self.get_layer_with_annotation()
+
+        self.login(username="admin")
+        annotation_data = {
+            "short_descr": "new",
+            "long_descr": "description",
+        }
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/"
+        rv = self.client.post(uri, json=annotation_data)
+        assert rv.status_code == 201
+        data = json.loads(rv.data.decode("utf-8"))
+        created_model: Annotation = 
db.session.query(Annotation).get(data.get("id"))
+        assert created_model is not None
+        assert created_model.short_descr == annotation_data["short_descr"]
+        assert created_model.long_descr == annotation_data["long_descr"]
+
+        # Rollback changes
+        db.session.delete(created_model)
+        db.session.commit()
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_create_annotation_uniqueness(self):
+        """
+        Annotation Api: Test create annotation uniqueness
+        """
+        layer = self.get_layer_with_annotation()
+
+        self.login(username="admin")
+        annotation_data = {
+            "short_descr": "short_descr2",
+            "long_descr": "description",
+        }
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/"
+        rv = self.client.post(uri, json=annotation_data)
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data == {
+            "message": {
+                "short_descr": ["Short description must be unique for this 
layer"]
+            }
+        }
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_update_annotation(self):
+        """
+        Annotation Api: Test update annotation
+        """
+        layer = self.get_layer_with_annotation()
+        annotation = (
+            db.session.query(Annotation)
+            .filter(Annotation.short_descr == "short_descr2")
+            .one_or_none()
+        )
+
+        self.login(username="admin")
+        annotation_layer_data = {
+            "short_descr": "changed_name",
+            "long_descr": "changed_description",
+        }
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/{annotation.id}"
+        rv = self.client.put(uri, json=annotation_layer_data)
+        assert rv.status_code == 200
+        updated_model: Annotation = 
db.session.query(Annotation).get(annotation.id)
+        assert updated_model is not None
+        assert updated_model.short_descr == 
annotation_layer_data["short_descr"]
+        assert updated_model.long_descr == annotation_layer_data["long_descr"]
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_update_annotation_uniqueness(self):
+        """
+        Annotation Api: Test update annotation uniqueness
+        """
+        layer = self.get_layer_with_annotation()
+        annotation = (
+            db.session.query(Annotation)
+            .filter(Annotation.short_descr == "short_descr2")
+            .one_or_none()
+        )
+
+        self.login(username="admin")
+        annotation_layer_data = {
+            "short_descr": "short_descr3",
+            "long_descr": "changed_description",
+        }
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/{annotation.id}"
+        rv = self.client.put(uri, json=annotation_layer_data)
+        assert rv.status_code == 422
+        data = json.loads(rv.data.decode("utf-8"))
+        assert data == {
+            "message": {
+                "short_descr": ["Short description must be unique for this 
layer"]
+            }
+        }
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_update_annotation_not_found(self):
+        """
+        Annotation Api: Test update annotation not found
+        """
+        layer = self.get_layer_with_annotation()
+        max_id = db.session.query(func.max(Annotation.id)).scalar()
+
+        self.login(username="admin")
+        annotation_layer_data = {
+            "short_descr": "changed_name",
+        }
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/{max_id + 1}"
+        rv = self.client.put(uri, json=annotation_layer_data)
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_delete_annotation(self):
+        """
+        Annotation Api: Test update annotation
+        """
+        layer = self.get_layer_with_annotation()
+        annotation = (
+            db.session.query(Annotation)
+            .filter(Annotation.short_descr == "short_descr1")
+            .one_or_none()
+        )
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation/{annotation.id}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 200
+        updated_model = db.session.query(Annotation).get(annotation.id)
+        assert updated_model is None
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_delete_annotation_not_found(self):
+        """
+        Annotation Api: Test delete annotation not found
+        """
+        layer = self.get_layer_with_annotation()
+        max_id = db.session.query(func.max(Annotation.id)).scalar()
+        self.login(username="admin")
+        uri = f"api/v1/annotation_layer/{layer.id}/annotation{max_id + 1}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 404
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_bulk_delete_annotation(self):
+        """
+        Annotation Api: Test bulk delete annotation
+        """
+        layer = self.get_layer_with_annotation()
+        query_annotations = db.session.query(Annotation).filter(
+            Annotation.layer == layer
+        )
+
+        annotations = query_annotations.all()
+        annotations_ids = [annotation.id for annotation in annotations]
+        self.login(username="admin")
+        uri = 
f"api/v1/annotation_layer/{layer.id}/annotation/?q={prison.dumps(annotations_ids)}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 200
+        deleted_annotations = query_annotations.all()
+        assert deleted_annotations == []
+        response = json.loads(rv.data.decode("utf-8"))
+        expected_response = {"message": f"Deleted {len(annotations_ids)} 
annotations"}
+        assert response == expected_response
+
+    @pytest.mark.usefixtures("create_annotation_layers")
+    def test_bulk_delete_annotation_not_found(self):
+        """
+        Annotation Api: Test bulk delete annotation not found
+        """
+        layer = self.get_layer_with_annotation()
+        query_annotations = db.session.query(Annotation).filter(
+            Annotation.layer == layer
+        )
+
+        annotations = query_annotations.all()
+        annotations_ids = [annotation.id for annotation in annotations]
+
+        max_id = db.session.query(func.max(Annotation.id)).scalar()
+
+        annotations_ids.append(max_id + 1)
+        self.login(username="admin")
+        uri = 
f"api/v1/annotation_layer/{layer.id}/annotation/?q={prison.dumps(annotations_ids)}"
+        rv = self.client.delete(uri)
+        assert rv.status_code == 404
diff --git a/tests/core_tests.py b/tests/core_tests.py
index 3d99818..64abd94 100644
--- a/tests/core_tests.py
+++ b/tests/core_tests.py
@@ -199,11 +199,16 @@ class TestCore(SupersetTestCase):
         self.assertIn("id", resp_annotations["result"][0])
         self.assertIn("name", resp_annotations["result"][0])
 
-        layer = self.get_resp(
+        response = self.get_resp(
             f"/superset/annotation_json/{layer.id}?form_data="
             + quote(json.dumps({"time_range": "100 years ago : now"}))
         )
-        self.assertIn("my_annotation", layer)
+        assert "my_annotation" in response
+
+        # Rollback changes
+        db.session.delete(annotation)
+        db.session.delete(layer)
+        db.session.commit()
 
     def test_admin_only_permissions(self):
         def assert_admin_permission_in(role_name, assert_func):

Reply via email to