diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json index ad299216fc..847d6e87df 100644 --- a/docs/static/resources/openapi.json +++ b/docs/static/resources/openapi.json @@ -1783,12 +1783,21 @@ "changed_on_delta_humanized": { "readOnly": true }, + "changed_on_dttm": { + "readOnly": true + }, "changed_on_utc": { "readOnly": true }, "created_by": { "$ref": "#/components/schemas/ChartDataRestApi.get_list.User2" }, + "created_by_name": { + "readOnly": true + }, + "created_by_url": { + "readOnly": true + }, "created_on_delta_humanized": { "readOnly": true }, @@ -1821,6 +1830,9 @@ "edit_url": { "readOnly": true }, + "form_data": { + "readOnly": true + }, "id": { "format": "int32", "type": "integer" @@ -1848,6 +1860,9 @@ "nullable": true, "type": "string" }, + "slice_url": { + "readOnly": true + }, "table": { "$ref": "#/components/schemas/ChartDataRestApi.get_list.SqlaTable" }, @@ -2576,12 +2591,21 @@ "changed_on_delta_humanized": { "readOnly": true }, + "changed_on_dttm": { + "readOnly": true + }, "changed_on_utc": { "readOnly": true }, "created_by": { "$ref": "#/components/schemas/ChartRestApi.get_list.User2" }, + "created_by_name": { + "readOnly": true + }, + "created_by_url": { + "readOnly": true + }, "created_on_delta_humanized": { "readOnly": true }, @@ -2614,6 +2638,9 @@ "edit_url": { "readOnly": true }, + "form_data": { + "readOnly": true + }, "id": { "format": "int32", "type": "integer" @@ -2641,6 +2668,9 @@ "nullable": true, "type": "string" }, + "slice_url": { + "readOnly": true + }, "table": { "$ref": "#/components/schemas/ChartRestApi.get_list.SqlaTable" }, diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx index c444e027d9..e83c6594a4 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.jsx @@ -17,6 +17,7 @@ * under the License. */ import React from 'react'; +import rison from 'rison'; import PropTypes from 'prop-types'; import { CompactPicker } from 'react-color'; import Button from 'src/components/Button'; @@ -315,34 +316,45 @@ class AnnotationLayer extends React.PureComponent { }); }); } else if (requiresQuery(sourceType)) { - SupersetClient.get({ endpoint: '/superset/user_slices' }).then( - ({ json }) => { - const registry = getChartMetadataRegistry(); - this.setState({ - isLoadingOptions: false, - valueOptions: json - .filter(x => { - const metadata = registry.get(x.viz_type); - return ( - metadata && metadata.canBeAnnotationType(annotationType) - ); - }) - .map(x => ({ - value: x.id, - label: x.title, - slice: { - ...x, - data: { - ...x.data, - groupby: x.data.groupby?.map(column => - getColumnLabel(column), - ), - }, + const queryParams = rison.encode({ + filters: [ + { + col: 'id', + opr: 'chart_owned_created_favored_by_me', + value: true, + }, + ], + order_column: 'slice_name', + order_direction: 'asc', + page: 0, + page_size: 25, + }); + SupersetClient.get({ + endpoint: `/api/v1/chart/?q=${queryParams}`, + }).then(({ json }) => { + const registry = getChartMetadataRegistry(); + this.setState({ + isLoadingOptions: false, + valueOptions: json.result + .filter(x => { + const metadata = registry.get(x.viz_type); + return metadata && metadata.canBeAnnotationType(annotationType); + }) + .map(x => ({ + value: x.id, + label: x.slice_name, + slice: { + ...x, + data: { + ...x.form_data, + groupby: x.form_data.groupby?.map(column => + getColumnLabel(column), + ), }, - })), - }); - }, - ); + }, + })), + }); + }); } else { this.setState({ isLoadingOptions: false, diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx index c5fed0b865..c1969769e6 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx @@ -40,9 +40,11 @@ beforeAll(() => { result: [{ label: 'Chart A', value: 'a' }], }); - fetchMock.get('glob:*/superset/user_slices*', [ - { id: 'a', title: 'Chart A', viz_type: 'table', data: {} }, - ]); + fetchMock.get('glob:*/api/v1/chart/*', { + result: [ + { id: 'a', slice_name: 'Chart A', viz_type: 'table', form_data: {} }, + ], + }); setupColors(); diff --git a/superset-frontend/src/profile/components/Favorites.tsx b/superset-frontend/src/profile/components/Favorites.tsx index 1e28cd989a..1a52c8b047 100644 --- a/superset-frontend/src/profile/components/Favorites.tsx +++ b/superset-frontend/src/profile/components/Favorites.tsx @@ -22,7 +22,7 @@ import moment from 'moment'; import { t } from '@superset-ui/core'; import { DashboardResponse, BootstrapUser } from 'src/types/bootstrapTypes'; import TableLoader from '../../components/TableLoader'; -import { Slice } from '../types'; +import { Chart } from '../types'; interface FavoritesProps { user: BootstrapUser; @@ -30,16 +30,31 @@ interface FavoritesProps { export default class Favorites extends React.PureComponent { renderSliceTable() { - const mutator = (data: Slice[]) => - data.map(slice => ({ - slice: {slice.title}, - creator: {slice.creator}, - favorited: moment.utc(slice.dttm).fromNow(), - _favorited: slice.dttm, + const mutator = (payload: { result: Chart[] }) => + payload.result.map(slice => ({ + slice: {slice.slice_name}, + creator: {slice.created_by_name}, + favorited: moment.utc(slice.changed_on_dttm).fromNow(), + _favorited: slice.changed_on_dttm, })); + + const query = rison.encode({ + filters: [ + { + col: 'id', + opr: 'chart_is_favorite', + value: true, + }, + ], + order_column: 'slice_name', + order_direction: 'asc', + page: 0, + page_size: 25, + }); + return ( Query: + # If anonymous user filter nothing + if security_manager.current_user is None: + return query + + owner_ids_query = ( + db.session.query(Slice.id) + .join(Slice.owners) + .filter(security_manager.user_model.id == get_user_id()) + ) + + return query.join( + FavStar, + and_( + FavStar.user_id == get_user_id(), + FavStar.class_name == "slice", + Slice.id == FavStar.obj_id, + ), + isouter=True, + ).filter( # pylint: disable=comparison-with-callable + or_( + Slice.id.in_(owner_ids_query), + Slice.created_by_fk == get_user_id(), + Slice.changed_by_fk == get_user_id(), + FavStar.user_id == get_user_id(), + ) + ) diff --git a/superset/constants.py b/superset/constants.py index 5a1679d6db..f41cd47ee2 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -147,6 +147,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = { "delete_ssh_tunnel": "write", "get_updated_since": "read", "stop_query": "read", + "get_user_slices": "read", "schemas_access_for_file_upload": "read", "get_objects": "read", "get_all_objects": "read", diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 0c52465caa..0790e3709a 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -89,6 +89,7 @@ from superset.superset_typing import ( ) from superset.utils import core as utils from superset.utils.core import get_user_id +from superset.utils.dates import datetime_to_epoch if TYPE_CHECKING: from superset.connectors.sqla.models import SqlMetric, TableColumn @@ -492,6 +493,12 @@ class AuditMixinNullable(AuditMixin): nullable=True, ) + @property + def created_by_name(self) -> str: + if self.created_by: + return escape("{}".format(self.created_by)) + return "" + @property def changed_by_name(self) -> str: if self.changed_by: @@ -514,6 +521,10 @@ class AuditMixinNullable(AuditMixin): def changed_on_delta_humanized(self) -> str: return self.changed_on_humanized + @renders("changed_on") + def changed_on_dttm(self) -> float: + return datetime_to_epoch(self.changed_on) + @renders("created_on") def created_on_delta_humanized(self) -> str: return self.created_on_humanized diff --git a/superset/models/slice.py b/superset/models/slice.py index 9ab4039a93..6bdd1bda6e 100644 --- a/superset/models/slice.py +++ b/superset/models/slice.py @@ -332,6 +332,12 @@ class Slice( # pylint: disable=too-many-public-methods name = escape(self.chart) return Markup(f'{name}') + @property + def created_by_url(self) -> str: + if not self.created_by: + return "" + return f"/superset/profile/{self.created_by.username}" + @property def changed_by_url(self) -> str: return f"/superset/profile/{self.changed_by.username}" # type: ignore diff --git a/superset/views/core.py b/superset/views/core.py index f2dfa5d140..2ad58cc7bb 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -1559,6 +1559,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods @event_logger.log_this @expose("/user_slices", methods=["GET"]) @expose("/user_slices//", methods=["GET"]) + @deprecated() def user_slices(self, user_id: Optional[int] = None) -> FlaskResponse: """List of slices a user owns, created, modified or faved""" if not user_id: @@ -1644,6 +1645,7 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods @event_logger.log_this @expose("/fave_slices", methods=["GET"]) @expose("/fave_slices//", methods=["GET"]) + @deprecated() def fave_slices(self, user_id: Optional[int] = None) -> FlaskResponse: """Favorite slices for a user""" if user_id is None: diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index e3a5859886..62da324d56 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -28,7 +28,7 @@ from sqlalchemy import and_ from sqlalchemy.sql import func from superset.connectors.sqla.models import SqlaTable -from superset.extensions import cache_manager, db +from superset.extensions import cache_manager, db, security_manager from superset.models.core import Database, FavStar, FavStarClassName from superset.models.dashboard import Dashboard from superset.reports.models import ReportSchedule, ReportScheduleType @@ -1600,3 +1600,29 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin, InsertChartMixin): self.assertEqual(rv.status_code, 200) data = json.loads(rv.data.decode("utf-8")) self.assertEqual(data["count"], 8) + + @pytest.mark.usefixtures("create_charts") + def test_gets_owned_created_favorited_by_me_filter(self): + """ + Chart API: Test ChartOwnedCreatedFavoredByMeFilter + """ + self.login(username="admin") + arguments = { + "filters": [ + { + "col": "id", + "opr": "chart_owned_created_favored_by_me", + "value": True, + } + ], + "order_column": "slice_name", + "order_direction": "asc", + "page": 0, + "page_size": 25, + } + rv = self.client.get(f"api/v1/chart/?q={prison.dumps(arguments)}") + self.assertEqual(rv.status_code, 200) + data = json.loads(rv.data.decode("utf-8")) + + assert data["result"][0]["slice_name"] == "name0" + assert data["result"][0]["datasource_id"] == 1 diff --git a/tests/integration_tests/charts/commands_tests.py b/tests/integration_tests/charts/commands_tests.py index fb2073499b..e38a76ef51 100644 --- a/tests/integration_tests/charts/commands_tests.py +++ b/tests/integration_tests/charts/commands_tests.py @@ -377,7 +377,7 @@ class TestChartsUpdateCommand(SupersetTestCase): def test_query_context_update_command(self, mock_sm_g, mock_g): """ Test that a user can generate the chart query context - payloadwithout affecting owners + payload without affecting owners """ chart = db.session.query(Slice).all()[0] pk = chart.id diff --git a/tests/unit_tests/charts/dao/dao_tests.py b/tests/unit_tests/charts/dao/dao_tests.py index faec8694db..72ae9dbba7 100644 --- a/tests/unit_tests/charts/dao/dao_tests.py +++ b/tests/unit_tests/charts/dao/dao_tests.py @@ -37,8 +37,8 @@ def session_with_data(session: Session) -> Iterator[Session]: datasource_name="tmp_perm_table", slice_name="slice_name", ) - session.add(slice_obj) + session.commit() yield session session.rollback()