mirror of https://github.com/apache/superset.git
[dashboards] New, API for Bulk delete (#8972)
* [dashboards] New, API for Bulk delete * [dashboards] Tests * [dashboards] Fix not found on multiple delete * [dashboards] Bulk delete partial deletes on not owned dashes * [dashboards] Improve OpenAPI spec and tests * [dashboards] Test for bad request * [dashboards] i18n * [dashboards] black * [dashboard] make bulk all or nothing * [dashboard] Log on sqlalchemy error * [dashboard] Log on sqlalchemy error
This commit is contained in:
parent
74158694c5
commit
d02cf2f509
|
@ -15,24 +15,30 @@
|
||||||
# specific language governing permissions and limitations
|
# specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from flask import current_app, make_response
|
from flask import current_app, g, make_response
|
||||||
from flask_appbuilder.api import expose, protect, rison, safe
|
from flask_appbuilder.api import expose, protect, rison, safe
|
||||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||||
|
from flask_babel import lazy_gettext as _, ngettext
|
||||||
from marshmallow import fields, post_load, pre_load, Schema, ValidationError
|
from marshmallow import fields, post_load, pre_load, Schema, ValidationError
|
||||||
from marshmallow.validate import Length
|
from marshmallow.validate import Length
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from superset.exceptions import SupersetException
|
from superset.exceptions import SupersetException, SupersetSecurityException
|
||||||
from superset.models.dashboard import Dashboard
|
from superset.models.dashboard import Dashboard
|
||||||
from superset.utils import core as utils
|
from superset.utils import core as utils
|
||||||
from superset.views.base import generate_download_headers
|
from superset.views.base import check_ownership, generate_download_headers
|
||||||
from superset.views.base_api import BaseOwnedModelRestApi
|
from superset.views.base_api import BaseOwnedModelRestApi
|
||||||
from superset.views.base_schemas import BaseOwnedSchema, validate_owner
|
from superset.views.base_schemas import BaseOwnedSchema, validate_owner
|
||||||
|
|
||||||
from .mixin import DashboardMixin
|
from .mixin import DashboardMixin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
|
||||||
|
|
||||||
|
|
||||||
class DashboardJSONMetadataSchema(Schema):
|
class DashboardJSONMetadataSchema(Schema):
|
||||||
timed_refresh_immune_slices = fields.List(fields.Integer())
|
timed_refresh_immune_slices = fields.List(fields.Integer())
|
||||||
|
@ -136,6 +142,7 @@ class DashboardRestApi(DashboardMixin, BaseOwnedModelRestApi):
|
||||||
"post": "add",
|
"post": "add",
|
||||||
"put": "edit",
|
"put": "edit",
|
||||||
"delete": "delete",
|
"delete": "delete",
|
||||||
|
"bulk_delete": "delete",
|
||||||
"info": "list",
|
"info": "list",
|
||||||
"related": "list",
|
"related": "list",
|
||||||
}
|
}
|
||||||
|
@ -172,6 +179,93 @@ class DashboardRestApi(DashboardMixin, BaseOwnedModelRestApi):
|
||||||
}
|
}
|
||||||
filter_rel_fields_field = {"owners": "first_name", "slices": "slice_name"}
|
filter_rel_fields_field = {"owners": "first_name", "slices": "slice_name"}
|
||||||
|
|
||||||
|
@expose("/", methods=["DELETE"])
|
||||||
|
@protect()
|
||||||
|
@safe
|
||||||
|
@rison(get_delete_ids_schema)
|
||||||
|
def bulk_delete(self, **kwargs): # pylint: disable=arguments-differ
|
||||||
|
"""Delete bulk Dashboards
|
||||||
|
---
|
||||||
|
delete:
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: q
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Dashboard bulk delete
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
401:
|
||||||
|
$ref: '#/components/responses/401'
|
||||||
|
403:
|
||||||
|
$ref: '#/components/responses/401'
|
||||||
|
404:
|
||||||
|
$ref: '#/components/responses/404'
|
||||||
|
422:
|
||||||
|
$ref: '#/components/responses/422'
|
||||||
|
500:
|
||||||
|
$ref: '#/components/responses/500'
|
||||||
|
"""
|
||||||
|
item_ids = kwargs["rison"]
|
||||||
|
query = self.datamodel.session.query(Dashboard).filter(
|
||||||
|
Dashboard.id.in_(item_ids)
|
||||||
|
)
|
||||||
|
items = self._base_filters.apply_all(query).all()
|
||||||
|
if not items:
|
||||||
|
return self.response_404()
|
||||||
|
# Check user ownership over the items
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
check_ownership(item)
|
||||||
|
except SupersetSecurityException as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Dashboard {item} was not deleted, "
|
||||||
|
f"because the user ({g.user}) does not own it"
|
||||||
|
)
|
||||||
|
return self.response(403, message=_("No dashboards deleted"))
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Error checking dashboard ownership {e}")
|
||||||
|
return self.response_422(message=str(e))
|
||||||
|
# bulk delete, first delete related data
|
||||||
|
for item in items:
|
||||||
|
try:
|
||||||
|
item.slices = []
|
||||||
|
item.owners = []
|
||||||
|
self.datamodel.session.merge(item)
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Error bulk deleting related data on dashboards {e}")
|
||||||
|
self.datamodel.session.rollback()
|
||||||
|
return self.response_422(message=str(e))
|
||||||
|
# bulk delete itself
|
||||||
|
try:
|
||||||
|
self.datamodel.session.query(Dashboard).filter(
|
||||||
|
Dashboard.id.in_(item_ids)
|
||||||
|
).delete(synchronize_session="fetch")
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
logger.error(f"Error bulk deleting dashboards {e}")
|
||||||
|
self.datamodel.session.rollback()
|
||||||
|
return self.response_422(message=str(e))
|
||||||
|
self.datamodel.session.commit()
|
||||||
|
return self.response(
|
||||||
|
200,
|
||||||
|
message=ngettext(
|
||||||
|
f"Deleted %(num)d dashboard",
|
||||||
|
f"Deleted %(num)d dashboards",
|
||||||
|
num=len(items),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@expose("/export/", methods=["GET"])
|
@expose("/export/", methods=["GET"])
|
||||||
@protect()
|
@protect()
|
||||||
@safe
|
@safe
|
||||||
|
|
|
@ -78,6 +78,44 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
|
||||||
model = db.session.query(models.Dashboard).get(dashboard_id)
|
model = db.session.query(models.Dashboard).get(dashboard_id)
|
||||||
self.assertEqual(model, None)
|
self.assertEqual(model, None)
|
||||||
|
|
||||||
|
def test_delete_bulk_dashboards(self):
|
||||||
|
"""
|
||||||
|
Dashboard API: Test delete bulk
|
||||||
|
"""
|
||||||
|
admin_id = self.get_user("admin").id
|
||||||
|
dashboard_count = 4
|
||||||
|
dashboard_ids = list()
|
||||||
|
for dashboard_name_index in range(dashboard_count):
|
||||||
|
dashboard_ids.append(
|
||||||
|
self.insert_dashboard(
|
||||||
|
f"title{dashboard_name_index}",
|
||||||
|
f"slug{dashboard_name_index}",
|
||||||
|
[admin_id],
|
||||||
|
).id
|
||||||
|
)
|
||||||
|
self.login(username="admin")
|
||||||
|
argument = dashboard_ids
|
||||||
|
uri = f"api/v1/dashboard/?q={prison.dumps(argument)}"
|
||||||
|
rv = self.client.delete(uri)
|
||||||
|
self.assertEqual(rv.status_code, 200)
|
||||||
|
response = json.loads(rv.data.decode("utf-8"))
|
||||||
|
expected_response = {"message": f"Deleted {dashboard_count} dashboards"}
|
||||||
|
self.assertEqual(response, expected_response)
|
||||||
|
for dashboard_id in dashboard_ids:
|
||||||
|
model = db.session.query(models.Dashboard).get(dashboard_id)
|
||||||
|
self.assertEqual(model, None)
|
||||||
|
|
||||||
|
def test_delete_bulk_dashboards_bad_request(self):
|
||||||
|
"""
|
||||||
|
Dashboard API: Test delete bulk bad request
|
||||||
|
"""
|
||||||
|
dashboard_ids = [1, "a"]
|
||||||
|
self.login(username="admin")
|
||||||
|
argument = dashboard_ids
|
||||||
|
uri = f"api/v1/dashboard/?q={prison.dumps(argument)}"
|
||||||
|
rv = self.client.delete(uri)
|
||||||
|
self.assertEqual(rv.status_code, 400)
|
||||||
|
|
||||||
def test_delete_not_found_dashboard(self):
|
def test_delete_not_found_dashboard(self):
|
||||||
"""
|
"""
|
||||||
Dashboard API: Test not found delete
|
Dashboard API: Test not found delete
|
||||||
|
@ -88,6 +126,17 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
|
||||||
rv = self.client.delete(uri)
|
rv = self.client.delete(uri)
|
||||||
self.assertEqual(rv.status_code, 404)
|
self.assertEqual(rv.status_code, 404)
|
||||||
|
|
||||||
|
def test_delete_bulk_dashboards_not_found(self):
|
||||||
|
"""
|
||||||
|
Dashboard API: Test delete bulk not found
|
||||||
|
"""
|
||||||
|
dashboard_ids = [1001, 1002]
|
||||||
|
self.login(username="admin")
|
||||||
|
argument = dashboard_ids
|
||||||
|
uri = f"api/v1/dashboard/?q={prison.dumps(argument)}"
|
||||||
|
rv = self.client.delete(uri)
|
||||||
|
self.assertEqual(rv.status_code, 404)
|
||||||
|
|
||||||
def test_delete_dashboard_admin_not_owned(self):
|
def test_delete_dashboard_admin_not_owned(self):
|
||||||
"""
|
"""
|
||||||
Dashboard API: Test admin delete not owned
|
Dashboard API: Test admin delete not owned
|
||||||
|
@ -102,6 +151,35 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
|
||||||
model = db.session.query(models.Dashboard).get(dashboard_id)
|
model = db.session.query(models.Dashboard).get(dashboard_id)
|
||||||
self.assertEqual(model, None)
|
self.assertEqual(model, None)
|
||||||
|
|
||||||
|
def test_delete_bulk_dashboard_admin_not_owned(self):
|
||||||
|
"""
|
||||||
|
Dashboard API: Test admin delete bulk not owned
|
||||||
|
"""
|
||||||
|
gamma_id = self.get_user("gamma").id
|
||||||
|
dashboard_count = 4
|
||||||
|
dashboard_ids = list()
|
||||||
|
for dashboard_name_index in range(dashboard_count):
|
||||||
|
dashboard_ids.append(
|
||||||
|
self.insert_dashboard(
|
||||||
|
f"title{dashboard_name_index}",
|
||||||
|
f"slug{dashboard_name_index}",
|
||||||
|
[gamma_id],
|
||||||
|
).id
|
||||||
|
)
|
||||||
|
|
||||||
|
self.login(username="admin")
|
||||||
|
argument = dashboard_ids
|
||||||
|
uri = f"api/v1/dashboard/?q={prison.dumps(argument)}"
|
||||||
|
rv = self.client.delete(uri)
|
||||||
|
response = json.loads(rv.data.decode("utf-8"))
|
||||||
|
self.assertEqual(rv.status_code, 200)
|
||||||
|
expected_response = {"message": f"Deleted {dashboard_count} dashboards"}
|
||||||
|
self.assertEqual(response, expected_response)
|
||||||
|
|
||||||
|
for dashboard_id in dashboard_ids:
|
||||||
|
model = db.session.query(models.Dashboard).get(dashboard_id)
|
||||||
|
self.assertEqual(model, None)
|
||||||
|
|
||||||
def test_delete_dashboard_not_owned(self):
|
def test_delete_dashboard_not_owned(self):
|
||||||
"""
|
"""
|
||||||
Dashboard API: Test delete try not owned
|
Dashboard API: Test delete try not owned
|
||||||
|
@ -127,6 +205,68 @@ class DashboardApiTests(SupersetTestCase, ApiOwnersTestCaseMixin):
|
||||||
db.session.delete(user_alpha2)
|
db.session.delete(user_alpha2)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
def test_delete_bulk_dashboard_not_owned(self):
|
||||||
|
"""
|
||||||
|
Dashboard API: Test delete bulk try not owned
|
||||||
|
"""
|
||||||
|
user_alpha1 = self.create_user(
|
||||||
|
"alpha1", "password", "Alpha", email="alpha1@superset.org"
|
||||||
|
)
|
||||||
|
user_alpha2 = self.create_user(
|
||||||
|
"alpha2", "password", "Alpha", email="alpha2@superset.org"
|
||||||
|
)
|
||||||
|
existing_slice = (
|
||||||
|
db.session.query(Slice).filter_by(slice_name="Girl Name Cloud").first()
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard_count = 4
|
||||||
|
dashboards = list()
|
||||||
|
for dashboard_name_index in range(dashboard_count):
|
||||||
|
dashboards.append(
|
||||||
|
self.insert_dashboard(
|
||||||
|
f"title{dashboard_name_index}",
|
||||||
|
f"slug{dashboard_name_index}",
|
||||||
|
[user_alpha1.id],
|
||||||
|
slices=[existing_slice],
|
||||||
|
published=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
owned_dashboard = self.insert_dashboard(
|
||||||
|
"title_owned",
|
||||||
|
"slug_owned",
|
||||||
|
[user_alpha2.id],
|
||||||
|
slices=[existing_slice],
|
||||||
|
published=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.login(username="alpha2", password="password")
|
||||||
|
|
||||||
|
# verify we can't delete not owned dashboards
|
||||||
|
arguments = [dashboard.id for dashboard in dashboards]
|
||||||
|
uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
|
||||||
|
rv = self.client.delete(uri)
|
||||||
|
self.assertEqual(rv.status_code, 403)
|
||||||
|
response = json.loads(rv.data.decode("utf-8"))
|
||||||
|
expected_response = {"message": "No dashboards deleted"}
|
||||||
|
self.assertEqual(response, expected_response)
|
||||||
|
|
||||||
|
# nothing is delete in bulk with a list of owned and not owned dashboards
|
||||||
|
arguments = [dashboard.id for dashboard in dashboards] + [owned_dashboard.id]
|
||||||
|
uri = f"api/v1/dashboard/?q={prison.dumps(arguments)}"
|
||||||
|
rv = self.client.delete(uri)
|
||||||
|
self.assertEqual(rv.status_code, 403)
|
||||||
|
response = json.loads(rv.data.decode("utf-8"))
|
||||||
|
expected_response = {"message": "No dashboards deleted"}
|
||||||
|
self.assertEqual(response, expected_response)
|
||||||
|
|
||||||
|
for dashboard in dashboards:
|
||||||
|
db.session.delete(dashboard)
|
||||||
|
db.session.delete(owned_dashboard)
|
||||||
|
db.session.delete(user_alpha1)
|
||||||
|
db.session.delete(user_alpha2)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
def test_create_dashboard(self):
|
def test_create_dashboard(self):
|
||||||
"""
|
"""
|
||||||
Dashboard API: Test create dashboard
|
Dashboard API: Test create dashboard
|
||||||
|
|
Loading…
Reference in New Issue