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 25f0fbd4cc371..18f23321ca0e9 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 waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; @@ -36,6 +37,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: { @@ -62,6 +64,16 @@ fetchMock.get(databasesEndpoint, { }); fetchMock.delete(databaseEndpoint, {}); +fetchMock.get(databaseRelatedEndpoint, { + charts: { + count: 0, + result: [], + }, + dashboards: { + count: 0, + result: [], + }, +}); describe('DatabaseList', () => { const wrapper = mount(, { context: { store } }); @@ -100,6 +112,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') @@ -114,6 +130,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 646ed3753697c..982851d683d47 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(false); + const [ + databaseCurrentlyDeleting, + setDatabaseCurrentlyDeleting, + ] = useState(null); const [currentDatabase, setCurrentDatabase] = useState( 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 ( {canDelete && ( - - {t('Are you sure you want to delete')}{' '} - {original.database_name}? - - } - onConfirm={handleDelete} + - {confirmDelete => ( - - - - - - )} - + + + + )} ); @@ -258,6 +273,24 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { /* TODO: add database logic here */ }} /> + {databaseCurrentlyDeleting && ( + { + if (databaseCurrentlyDeleting) { + handleDatabaseDelete(databaseCurrentlyDeleting); + } + }} + onHide={() => setDatabaseCurrentlyDeleting(null)} + open + title={t('Delete Database?')} + /> + )} className="database-list-view" diff --git a/superset/databases/api.py b/superset/databases/api.py index 9dd5438950b21..c3355f538f98f 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -36,12 +36,14 @@ 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 @@ def select_star( 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("//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 88009800ed2cc..804ac129e4b06 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 @@ def validate_update_uniqueness(database_id: int, database_name: str) -> bool: 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 dff09f9c015c6..a6a182f73dded 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 642f57c3d0347..6d82202df690d 100644 --- a/tests/databases/api_tests.py +++ b/tests/databases/api_tests.py @@ -651,3 +651,35 @@ def test_database_schemas_invalid_query(self): 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)