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):