feat: CRUD REST API for CSS Templates (#11114)

* feat: CSS Template CRUD API

* fix API docs

* fix copy pasta

* lint
This commit is contained in:
Daniel Vaz Gaspar 2020-10-01 11:46:25 +01:00 committed by GitHub
parent c4d96f64ff
commit fdb26f6131
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 737 additions and 0 deletions

View File

@ -140,6 +140,7 @@ class SupersetAppInitializer:
TableColumnInlineView,
TableModelView,
)
from superset.css_templates.api import CssTemplateRestApi
from superset.dashboards.api import DashboardRestApi
from superset.databases.api import DatabaseRestApi
from superset.datasets.api import DatasetRestApi
@ -197,6 +198,7 @@ class SupersetAppInitializer:
#
appbuilder.add_api(CacheRestApi)
appbuilder.add_api(ChartRestApi)
appbuilder.add_api(CssTemplateRestApi)
appbuilder.add_api(DashboardRestApi)
appbuilder.add_api(DatabaseRestApi)
appbuilder.add_api(DatasetRestApi)

View File

@ -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.

View File

@ -0,0 +1,133 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import Any
from flask import g, Response
from flask_appbuilder.api import expose, protect, rison, safe
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_babel import ngettext
from superset.constants import RouteMethod
from superset.css_templates.commands.bulk_delete import BulkDeleteCssTemplateCommand
from superset.css_templates.commands.exceptions import (
CssTemplateBulkDeleteFailedError,
CssTemplateNotFoundError,
)
from superset.css_templates.filters import CssTemplateAllTextFilter
from superset.css_templates.schemas import (
get_delete_ids_schema,
openapi_spec_methods_override,
)
from superset.models.core import CssTemplate
from superset.views.base_api import BaseSupersetModelRestApi, statsd_metrics
logger = logging.getLogger(__name__)
class CssTemplateRestApi(BaseSupersetModelRestApi):
datamodel = SQLAInterface(CssTemplate)
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
"bulk_delete", # not using RouteMethod since locally defined
}
class_permission_name = "CssTemplateModelView"
resource_name = "css_template"
allow_browser_login = True
show_columns = [
"created_by.first_name",
"created_by.id",
"created_by.last_name",
"css",
"id",
"template_name",
]
list_columns = [
"changed_on_delta_humanized",
"created_on",
"created_by.first_name",
"created_by.id",
"created_by.last_name",
"css",
"id",
"template_name",
]
add_columns = ["css", "template_name"]
edit_columns = add_columns
order_columns = ["template_name"]
search_filters = {"template_name": [CssTemplateAllTextFilter]}
apispec_parameter_schemas = {
"get_delete_ids_schema": get_delete_ids_schema,
}
openapi_spec_tag = "CSS Templates"
openapi_spec_methods = openapi_spec_methods_override
@expose("/", methods=["DELETE"])
@protect()
@safe
@statsd_metrics
@rison(get_delete_ids_schema)
def bulk_delete(self, **kwargs: Any) -> Response:
"""Delete bulk CSS Templates
---
delete:
description: >-
Deletes multiple css templates in a bulk operation.
parameters:
- in: query
name: q
content:
application/json:
schema:
$ref: '#/components/schemas/get_delete_ids_schema'
responses:
200:
description: CSS templates bulk delete
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
500:
$ref: '#/components/responses/500'
"""
item_ids = kwargs["rison"]
try:
BulkDeleteCssTemplateCommand(g.user, item_ids).run()
return self.response(
200,
message=ngettext(
"Deleted %(num)d css template",
"Deleted %(num)d css templates",
num=len(item_ids),
),
)
except CssTemplateNotFoundError:
return self.response_404()
except CssTemplateBulkDeleteFailedError as ex:
return self.response_422(message=str(ex))

View File

@ -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.

View File

@ -0,0 +1,53 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import List, Optional
from flask_appbuilder.security.sqla.models import User
from superset.commands.base import BaseCommand
from superset.css_templates.commands.exceptions import (
CssTemplateBulkDeleteFailedError,
CssTemplateNotFoundError,
)
from superset.css_templates.dao import CssTemplateDAO
from superset.dao.exceptions import DAODeleteFailedError
from superset.models.core import CssTemplate
logger = logging.getLogger(__name__)
class BulkDeleteCssTemplateCommand(BaseCommand):
def __init__(self, user: User, model_ids: List[int]):
self._actor = user
self._model_ids = model_ids
self._models: Optional[List[CssTemplate]] = None
def run(self) -> None:
self.validate()
try:
CssTemplateDAO.bulk_delete(self._models)
return None
except DAODeleteFailedError as ex:
logger.exception(ex.exception)
raise CssTemplateBulkDeleteFailedError()
def validate(self) -> None:
# Validate/populate model exists
self._models = CssTemplateDAO.find_by_ids(self._model_ids)
if not self._models or len(self._models) != len(self._model_ids):
raise CssTemplateNotFoundError()

View File

@ -0,0 +1,27 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from flask_babel import lazy_gettext as _
from superset.commands.exceptions import CommandException, DeleteFailedError
class CssTemplateBulkDeleteFailedError(DeleteFailedError):
message = _("CSS template could not be deleted.")
class CssTemplateNotFoundError(CommandException):
message = _("CSS template not found.")

View File

@ -0,0 +1,45 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import logging
from typing import List, Optional
from sqlalchemy.exc import SQLAlchemyError
from superset.dao.base import BaseDAO
from superset.dao.exceptions import DAODeleteFailedError
from superset.extensions import db
from superset.models.core import CssTemplate
logger = logging.getLogger(__name__)
class CssTemplateDAO(BaseDAO):
model_cls = CssTemplate
@staticmethod
def bulk_delete(models: Optional[List[CssTemplate]], commit: bool = True) -> None:
item_ids = [model.id for model in models] if models else []
try:
db.session.query(CssTemplate).filter(CssTemplate.id.in_(item_ids)).delete(
synchronize_session="fetch"
)
if commit:
db.session.commit()
except SQLAlchemyError:
if commit:
db.session.rollback()
raise DAODeleteFailedError()

View File

@ -0,0 +1,40 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import Any
from flask_babel import lazy_gettext as _
from sqlalchemy import or_
from sqlalchemy.orm.query import Query
from superset.models.core import CssTemplate
from superset.views.base import BaseFilter
class CssTemplateAllTextFilter(BaseFilter): # pylint: disable=too-few-public-methods
name = _("All Text")
arg_name = "css_template_all_text"
def apply(self, query: Query, value: Any) -> Query:
if not value:
return query
ilike_value = f"%{value}%"
return query.filter(
or_(
CssTemplate.template_name.ilike(ilike_value),
CssTemplate.css.ilike(ilike_value),
)
)

View File

@ -0,0 +1,33 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
openapi_spec_methods_override = {
"get": {"get": {"description": "Get a CSS template"}},
"get_list": {
"get": {
"description": "Get a list of CSS templates, use Rison or JSON "
"query parameters for filtering, sorting,"
" pagination and for selecting specific"
" columns and metadata.",
}
},
"post": {"post": {"description": "Create a CSS template"}},
"put": {"put": {"description": "Update a CSS template"}},
"delete": {"delete": {"description": "Delete CSS template"}},
}
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}

View File

@ -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.

View File

@ -0,0 +1,356 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# isort:skip_file
"""Unit tests for Superset"""
import json
import pytest
import prison
from sqlalchemy.sql import func
import tests.test_app
from superset import db
from superset.models.core import CssTemplate
from superset.utils.core import get_example_database
from tests.base_tests import SupersetTestCase
CSS_TEMPLATES_FIXTURE_COUNT = 5
class TestCssTemplateApi(SupersetTestCase):
def insert_css_template(
self, template_name: str, css: str, created_by_username: str = "admin",
) -> CssTemplate:
admin = self.get_user(created_by_username)
css_template = CssTemplate(
template_name=template_name, css=css, created_by=admin
)
db.session.add(css_template)
db.session.commit()
return css_template
@pytest.fixture()
def create_css_templates(self):
with self.create_app().app_context():
css_templates = []
for cx in range(CSS_TEMPLATES_FIXTURE_COUNT):
css_templates.append(
self.insert_css_template(
template_name=f"template_name{cx}", css=f"css{cx}"
)
)
yield css_templates
# rollback changes
for css_template in css_templates:
db.session.delete(css_template)
db.session.commit()
@pytest.mark.usefixtures("create_css_templates")
def test_get_list_css_template(self):
"""
CSS Template API: Test get list css template
"""
css_templates = db.session.query(CssTemplate).all()
self.login(username="admin")
uri = f"api/v1/css_template/"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(css_templates)
expected_columns = [
"changed_on_delta_humanized",
"created_on",
"created_by",
"template_name",
"css",
]
for expected_column in expected_columns:
assert expected_column in data["result"][0]
@pytest.mark.usefixtures("create_css_templates")
def test_get_list_sort_css_template(self):
"""
CSS Template API: Test get list and sort CSS Template
"""
css_templates = (
db.session.query(CssTemplate)
.order_by(CssTemplate.template_name.asc())
.all()
)
self.login(username="admin")
query_string = {"order_column": "template_name", "order_direction": "asc"}
uri = f"api/v1/css_template/?q={prison.dumps(query_string)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(css_templates)
for i, query in enumerate(css_templates):
assert query.template_name == data["result"][i]["template_name"]
@pytest.mark.usefixtures("create_css_templates")
def test_get_list_custom_filter_css_template(self):
"""
CSS Template API: Test get list and custom filter
"""
self.login(username="admin")
all_css_templates = (
db.session.query(CssTemplate).filter(CssTemplate.css.ilike("%css2%")).all()
)
query_string = {
"filters": [
{
"col": "template_name",
"opr": "css_template_all_text",
"value": "css2",
}
],
}
uri = f"api/v1/css_template/?q={prison.dumps(query_string)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(all_css_templates)
all_css_templates = (
db.session.query(CssTemplate)
.filter(CssTemplate.template_name.ilike("%template_name3%"))
.all()
)
query_string = {
"filters": [
{
"col": "template_name",
"opr": "css_template_all_text",
"value": "template_name3",
}
],
}
uri = f"api/v1/css_template/?q={prison.dumps(query_string)}"
rv = self.get_assert_metric(uri, "get_list")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
assert data["count"] == len(all_css_templates)
def test_info_css_template(self):
"""
CssTemplate API: Test info
"""
self.login(username="admin")
uri = f"api/v1/css_template/_info"
rv = self.get_assert_metric(uri, "info")
assert rv.status_code == 200
@pytest.mark.usefixtures("create_css_templates")
def test_get_css_template(self):
"""
CSS Template API: Test get CSS Template
"""
css_template = (
db.session.query(CssTemplate)
.filter(CssTemplate.template_name == "template_name1")
.one_or_none()
)
self.login(username="admin")
uri = f"api/v1/css_template/{css_template.id}"
rv = self.get_assert_metric(uri, "get")
assert rv.status_code == 200
expected_result = {
"id": css_template.id,
"template_name": "template_name1",
"css": "css1",
"created_by": {
"first_name": css_template.created_by.first_name,
"id": css_template.created_by.id,
"last_name": css_template.created_by.last_name,
},
}
data = json.loads(rv.data.decode("utf-8"))
for key, value in data["result"].items():
assert value == expected_result[key]
@pytest.mark.usefixtures("create_css_templates")
def test_get_css_template_not_found(self):
"""
CSS Template API: Test get CSS Template not found
"""
max_id = db.session.query(func.max(CssTemplate.id)).scalar()
self.login(username="admin")
uri = f"api/v1/css_template/{max_id + 1}"
rv = self.client.get(uri)
assert rv.status_code == 404
def test_create_css_template(self):
"""
CSS Template API: Test create
"""
post_data = {
"template_name": "template_name_create",
"css": "css_create",
}
self.login(username="admin")
uri = f"api/v1/css_template/"
rv = self.client.post(uri, json=post_data)
data = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 201
css_template_id = data.get("id")
model = db.session.query(CssTemplate).get(css_template_id)
for key in post_data:
assert getattr(model, key) == data["result"][key]
# Rollback changes
db.session.delete(model)
db.session.commit()
@pytest.mark.usefixtures("create_css_templates")
def test_update_css_template(self):
"""
CSS Template API: Test update
"""
css_template = (
db.session.query(CssTemplate)
.filter(CssTemplate.template_name == "template_name1")
.all()[0]
)
put_data = {
"template_name": "template_name_changed",
"css": "css_changed",
}
self.login(username="admin")
uri = f"api/v1/css_template/{css_template.id}"
rv = self.client.put(uri, json=put_data)
assert rv.status_code == 200
model = db.session.query(CssTemplate).get(css_template.id)
assert model.template_name == "template_name_changed"
assert model.css == "css_changed"
@pytest.mark.usefixtures("create_css_templates")
def test_update_css_template_not_found(self):
"""
CSS Template API: Test update not found
"""
max_id = db.session.query(func.max(CssTemplate.id)).scalar()
self.login(username="admin")
put_data = {
"template_name": "template_name_changed",
"css": "css_changed",
}
uri = f"api/v1/css_template/{max_id + 1}"
rv = self.client.put(uri, json=put_data)
assert rv.status_code == 404
@pytest.mark.usefixtures("create_css_templates")
def test_delete_css_template(self):
"""
CSS Template API: Test delete
"""
css_template = (
db.session.query(CssTemplate)
.filter(CssTemplate.template_name == "template_name1")
.one_or_none()
)
self.login(username="admin")
uri = f"api/v1/css_template/{css_template.id}"
rv = self.client.delete(uri)
assert rv.status_code == 200
model = db.session.query(CssTemplate).get(css_template.id)
assert model is None
@pytest.mark.usefixtures("create_css_templates")
def test_delete_css_template_not_found(self):
"""
CSS Template API: Test delete not found
"""
max_id = db.session.query(func.max(CssTemplate.id)).scalar()
self.login(username="admin")
uri = f"api/v1/css_template/{max_id + 1}"
rv = self.client.delete(uri)
assert rv.status_code == 404
@pytest.mark.usefixtures("create_css_templates")
def test_delete_bulk_css_templates(self):
"""
CSS Template API: Test delete bulk
"""
css_templates = db.session.query(CssTemplate).all()
css_template_ids = [css_template.id for css_template in css_templates]
self.login(username="admin")
uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
expected_response = {
"message": f"Deleted {len(css_template_ids)} css templates"
}
assert response == expected_response
css_templates = db.session.query(CssTemplate).all()
assert css_templates == []
@pytest.mark.usefixtures("create_css_templates")
def test_delete_one_bulk_css_templates(self):
"""
CSS Template API: Test delete one in bulk
"""
css_template = db.session.query(CssTemplate).first()
css_template_ids = [css_template.id]
self.login(username="admin")
uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 200
response = json.loads(rv.data.decode("utf-8"))
expected_response = {"message": f"Deleted {len(css_template_ids)} css template"}
assert response == expected_response
css_template_ = db.session.query(CssTemplate).get(css_template_ids[0])
assert css_template_ is None
def test_delete_bulk_css_template_bad_request(self):
"""
CSS Template API: Test delete bulk bad request
"""
css_template_ids = [1, "a"]
self.login(username="admin")
uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 400
@pytest.mark.usefixtures("create_css_templates")
def test_delete_bulk_css_template_not_found(self):
"""
CSS Template API: Test delete bulk not found
"""
max_id = db.session.query(func.max(CssTemplate.id)).scalar()
css_template_ids = [max_id + 1, max_id + 2]
self.login(username="admin")
uri = f"api/v1/css_template/?q={prison.dumps(css_template_ids)}"
rv = self.delete_assert_metric(uri, "bulk_delete")
assert rv.status_code == 404