You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ta...@apache.org on 2020/09/09 02:08:01 UTC
[incubator-superset] branch master updated: feat: database delete
warning (#10800)
This is an automated email from the ASF dual-hosted git repository.
tai 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 50672bb feat: database delete warning (#10800)
50672bb is described below
commit 50672bb11baa614ff64a376bd09a9c74d1830666
Author: ʈᵃᵢ <td...@gmail.com>
AuthorDate: Tue Sep 8 18:54:02 2020 -0700
feat: database delete warning (#10800)
---
.../views/CRUD/data/database/DatabaseList_spec.jsx | 19 +++++
.../src/views/CRUD/data/database/DatabaseList.tsx | 91 +++++++++++++++-------
superset/databases/api.py | 61 +++++++++++++++
superset/databases/dao.py | 28 +++++++
superset/databases/schemas.py | 32 ++++++++
tests/databases/api_tests.py | 32 ++++++++
6 files changed, 234 insertions(+), 29 deletions(-)
diff --git a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx
index 9c3ea6a..7d80284 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/data/database/DatabaseList_spec.jsx
@@ -24,6 +24,7 @@ import { styledMount as mount } from 'spec/helpers/theming';
import DatabaseList from 'src/views/CRUD/data/database/DatabaseList';
import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal';
+import DeleteModal from 'src/components/DeleteModal';
import SubMenu from 'src/components/Menu/SubMenu';
import ListView from 'src/components/ListView';
import Filters from 'src/components/ListView/Filters';
@@ -37,6 +38,7 @@ const store = mockStore({});
const databasesInfoEndpoint = 'glob:*/api/v1/database/_info*';
const databasesEndpoint = 'glob:*/api/v1/database/?*';
const databaseEndpoint = 'glob:*/api/v1/database/*';
+const databaseRelatedEndpoint = 'glob:*/api/v1/database/*/related_objects*';
const mockdatabases = [...new Array(3)].map((_, i) => ({
changed_by: {
@@ -63,6 +65,16 @@ fetchMock.get(databasesEndpoint, {
});
fetchMock.delete(databaseEndpoint, {});
+fetchMock.get(databaseRelatedEndpoint, {
+ charts: {
+ count: 0,
+ result: [],
+ },
+ dashboards: {
+ count: 0,
+ result: [],
+ },
+});
describe('DatabaseList', () => {
const wrapper = mount(<DatabaseList />, { context: { store } });
@@ -101,6 +113,10 @@ describe('DatabaseList', () => {
});
await waitForComponentToPaint(wrapper);
+ expect(wrapper.find(DeleteModal).props().description).toMatchInlineSnapshot(
+ `"The database db 0 is linked to 0 charts that appear on 0 dashboards. Are you sure you want to continue? Deleting the database will break those objects."`,
+ );
+
act(() => {
wrapper
.find('#delete')
@@ -115,6 +131,9 @@ describe('DatabaseList', () => {
await waitForComponentToPaint(wrapper);
+ expect(fetchMock.calls(/database\/0\/related_objects/, 'GET')).toHaveLength(
+ 1,
+ );
expect(fetchMock.calls(/database\/0/, 'DELETE')).toHaveLength(1);
});
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
index a852619..ebe4fab 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
@@ -23,8 +23,8 @@ import React, { useState, useMemo } from 'react';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { createErrorHandler } from 'src/views/CRUD/utils';
import withToasts from 'src/messageToasts/enhancers/withToasts';
-import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
+import DeleteModal from 'src/components/DeleteModal';
import TooltipWrapper from 'src/components/TooltipWrapper';
import Icon from 'src/components/Icon';
import ListView, { Filters } from 'src/components/ListView';
@@ -34,6 +34,10 @@ import { DatabaseObject } from './types';
const PAGE_SIZE = 25;
+interface DatabaseDeleteObject extends DatabaseObject {
+ chart_count: number;
+ dashboard_count: number;
+}
interface DatabaseListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
@@ -63,10 +67,34 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
addDangerToast,
);
const [databaseModalOpen, setDatabaseModalOpen] = useState<boolean>(false);
+ const [
+ databaseCurrentlyDeleting,
+ setDatabaseCurrentlyDeleting,
+ ] = useState<DatabaseDeleteObject | null>(null);
const [currentDatabase, setCurrentDatabase] = useState<DatabaseObject | null>(
null,
);
+ const openDatabaseDeleteModal = (database: DatabaseObject) =>
+ SupersetClient.get({
+ endpoint: `/api/v1/database/${database.id}/related_objects/`,
+ })
+ .then(({ json = {} }) => {
+ setDatabaseCurrentlyDeleting({
+ ...database,
+ chart_count: json.charts.count,
+ dashboard_count: json.dashboards.count,
+ });
+ })
+ .catch(
+ createErrorHandler(errMsg =>
+ t(
+ 'An error occurred while fetching database related data: %s',
+ errMsg,
+ ),
+ ),
+ );
+
function handleDatabaseDelete({ id, database_name: dbName }: DatabaseObject) {
SupersetClient.delete({
endpoint: `/api/v1/database/${id}`,
@@ -198,41 +226,28 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
},
{
Cell: ({ row: { original } }: any) => {
- const handleDelete = () => handleDatabaseDelete(original);
+ const handleDelete = () => openDatabaseDeleteModal(original);
if (!canDelete) {
return null;
}
return (
<span className="actions">
{canDelete && (
- <ConfirmStatusChange
- title={t('Please Confirm')}
- description={
- <>
- {t('Are you sure you want to delete')}{' '}
- <b>{original.database_name}</b>?
- </>
- }
- onConfirm={handleDelete}
+ <span
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ data-test="database-delete"
+ onClick={handleDelete}
>
- {confirmDelete => (
- <span
- role="button"
- tabIndex={0}
- className="action-button"
- data-test="database-delete"
- onClick={confirmDelete}
- >
- <TooltipWrapper
- label="delete-action"
- tooltip={t('Delete database')}
- placement="bottom"
- >
- <Icon name="trash" />
- </TooltipWrapper>
- </span>
- )}
- </ConfirmStatusChange>
+ <TooltipWrapper
+ label="delete-action"
+ tooltip={t('Delete database')}
+ placement="bottom"
+ >
+ <Icon name="trash" />
+ </TooltipWrapper>
+ </span>
)}
</span>
);
@@ -298,6 +313,24 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) {
/* TODO: add database logic here */
}}
/>
+ {databaseCurrentlyDeleting && (
+ <DeleteModal
+ description={t(
+ 'The database %s is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the database will break those objects.',
+ databaseCurrentlyDeleting.database_name,
+ databaseCurrentlyDeleting.chart_count,
+ databaseCurrentlyDeleting.dashboard_count,
+ )}
+ onConfirm={() => {
+ if (databaseCurrentlyDeleting) {
+ handleDatabaseDelete(databaseCurrentlyDeleting);
+ }
+ }}
+ onHide={() => setDatabaseCurrentlyDeleting(null)}
+ open
+ title={t('Delete Database?')}
+ />
+ )}
<ListView<DatabaseObject>
className="database-list-view"
diff --git a/superset/databases/api.py b/superset/databases/api.py
index 9dd5438..c3355f5 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -36,12 +36,14 @@ from superset.databases.commands.exceptions import (
DatabaseUpdateFailedError,
)
from superset.databases.commands.update import UpdateDatabaseCommand
+from superset.databases.dao import DatabaseDAO
from superset.databases.decorators import check_datasource_access
from superset.databases.filters import DatabaseFilter
from superset.databases.schemas import (
database_schemas_query_schema,
DatabasePostSchema,
DatabasePutSchema,
+ DatabaseRelatedObjectsResponse,
SchemasResponseSchema,
SelectStarResponseSchema,
TableMetadataResponseSchema,
@@ -63,6 +65,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
"table_metadata",
"select_star",
"schemas",
+ "related_objects",
}
class_permission_name = "DatabaseView"
resource_name = "database"
@@ -148,6 +151,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
}
openapi_spec_tag = "Database"
openapi_spec_component_schemas = (
+ DatabaseRelatedObjectsResponse,
TableMetadataResponseSchema,
SelectStarResponseSchema,
SchemasResponseSchema,
@@ -501,3 +505,60 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
return self.response(404, message="Table not found on the database")
self.incr_stats("success", self.select_star.__name__)
return self.response(200, result=result)
+
+ @expose("/<int:pk>/related_objects/", methods=["GET"])
+ @protect()
+ @safe
+ @statsd_metrics
+ def related_objects(self, pk: int) -> Response:
+ """Get charts and dashboards count associated to a database
+ ---
+ get:
+ description:
+ Get charts and dashboards count associated to a database
+ parameters:
+ - in: path
+ name: pk
+ schema:
+ type: integer
+ responses:
+ 200:
+ 200:
+ description: Query result
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DatabaseRelatedObjectsResponse"
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ dataset = DatabaseDAO.find_by_id(pk)
+ if not dataset:
+ return self.response_404()
+ data = DatabaseDAO.get_related_objects(pk)
+ charts = [
+ {
+ "id": chart.id,
+ "slice_name": chart.slice_name,
+ "viz_type": chart.viz_type,
+ }
+ for chart in data["charts"]
+ ]
+ dashboards = [
+ {
+ "id": dashboard.id,
+ "json_metadata": dashboard.json_metadata,
+ "slug": dashboard.slug,
+ "title": dashboard.dashboard_title,
+ }
+ for dashboard in data["dashboards"]
+ ]
+ return self.response(
+ 200,
+ charts={"count": len(charts), "result": charts},
+ dashboards={"count": len(dashboards), "result": dashboards},
+ )
diff --git a/superset/databases/dao.py b/superset/databases/dao.py
index 8800980..804ac12 100644
--- a/superset/databases/dao.py
+++ b/superset/databases/dao.py
@@ -15,11 +15,14 @@
# specific language governing permissions and limitations
# under the License.
import logging
+from typing import Any, Dict
from superset.dao.base import BaseDAO
from superset.databases.filters import DatabaseFilter
from superset.extensions import db
from superset.models.core import Database
+from superset.models.dashboard import Dashboard
+from superset.models.slice import Slice
logger = logging.getLogger(__name__)
@@ -41,3 +44,28 @@ class DatabaseDAO(BaseDAO):
Database.database_name == database_name, Database.id != database_id,
)
return not db.session.query(database_query.exists()).scalar()
+
+ @classmethod
+ def get_related_objects(cls, database_id: int) -> Dict[str, Any]:
+ datasets = cls.find_by_id(database_id).tables
+ dataset_ids = [dataset.id for dataset in datasets]
+
+ charts = (
+ db.session.query(Slice)
+ .filter(
+ Slice.datasource_id.in_(dataset_ids), Slice.datasource_type == "table"
+ )
+ .all()
+ )
+ chart_ids = [chart.id for chart in charts]
+
+ dashboards = (
+ (
+ db.session.query(Dashboard)
+ .join(Dashboard.slices)
+ .filter(Slice.id.in_(chart_ids))
+ )
+ .distinct()
+ .all()
+ )
+ return dict(charts=charts, dashboards=dashboards)
diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py
index dff09f9..a6a182f 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -339,3 +339,35 @@ class SelectStarResponseSchema(Schema):
class SchemasResponseSchema(Schema):
result = fields.List(fields.String(description="A database schema name"))
+
+
+class DatabaseRelatedChart(Schema):
+ id = fields.Integer()
+ slice_name = fields.String()
+ viz_type = fields.String()
+
+
+class DatabaseRelatedDashboard(Schema):
+ id = fields.Integer()
+ json_metadata = fields.Dict()
+ slug = fields.String()
+ title = fields.String()
+
+
+class DatabaseRelatedCharts(Schema):
+ count = fields.Integer(description="Chart count")
+ result = fields.List(
+ fields.Nested(DatabaseRelatedChart), description="A list of dashboards"
+ )
+
+
+class DatabaseRelatedDashboards(Schema):
+ count = fields.Integer(description="Dashboard count")
+ result = fields.List(
+ fields.Nested(DatabaseRelatedDashboard), description="A list of dashboards"
+ )
+
+
+class DatabaseRelatedObjectsResponse(Schema):
+ charts = fields.Nested(DatabaseRelatedCharts)
+ dashboards = fields.Nested(DatabaseRelatedDashboards)
diff --git a/tests/databases/api_tests.py b/tests/databases/api_tests.py
index 642f57c..6d82202 100644
--- a/tests/databases/api_tests.py
+++ b/tests/databases/api_tests.py
@@ -651,3 +651,35 @@ class TestDatabaseApi(SupersetTestCase):
f"api/v1/database/{database.id}/schemas/?q={prison.dumps({'force': 'nop'})}"
)
self.assertEqual(rv.status_code, 400)
+
+ def test_get_database_related_objects(self):
+ """
+ Database API: Test get chart and dashboard count related to a database
+ :return:
+ """
+ self.login(username="admin")
+ database = get_example_database()
+ uri = f"api/v1/database/{database.id}/related_objects/"
+ rv = self.get_assert_metric(uri, "related_objects")
+ self.assertEqual(rv.status_code, 200)
+ response = json.loads(rv.data.decode("utf-8"))
+ self.assertEqual(response["charts"]["count"], 33)
+ self.assertEqual(response["dashboards"]["count"], 6)
+
+ def test_get_database_related_objects_not_found(self):
+ """
+ Database API: Test related objects not found
+ """
+ max_id = db.session.query(func.max(Database.id)).scalar()
+ # id does not exist and we get 404
+ invalid_id = max_id + 1
+ uri = f"api/v1/database/{invalid_id}/related_objects/"
+ self.login(username="admin")
+ rv = self.client.get(uri)
+ self.assertEqual(rv.status_code, 404)
+ self.logout()
+ self.login(username="gamma")
+ database = get_example_database()
+ uri = f"api/v1/database/{database.id}/related_objects/"
+ rv = self.client.get(uri)
+ self.assertEqual(rv.status_code, 404)