From 97ffb762d0216e745fb1d946b4b1a4beeb66bfdf Mon Sep 17 00:00:00 2001 From: Tresdon Jones Date: Thu, 11 Jul 2019 00:14:13 -0600 Subject: [PATCH] Add "Published" feature to dashboards (#4725) * Allow users to publish dashboards * Rework publish dashboards feature - The eye next to the title has been replaced with a [draft] badge - Published status is now toggled in the Header Action Dropdown - CRUD list shows published status * Fix linter errors * Update javascript tests * Add tests and change DashboardFilter Add some tests to make sure the published status is rendered and Make it so that users cannot see dashboards that are published if they don't have access to any of the slices within * Fix some linter errors * Remove commas from core.py * Fix some failing tests * More linter errors I introduced * Fix more linter errors I introduced * update alembic migration * Update design of publish dash feature * Upgrade migration version * Secure publish endpoint * Remove bad quotes * Give publish span its own style * fix publish rendering * Add new test for publish feature * Update migration * update slug in test * Update migration * Address reviwer comments * Fix linter errors * Add licenses * Remove fetchPublished(), use bootstrap data * Update migration * Update croniter to existing version * Fix linter errors * Upgrade DB Revisions * Fix flake8 linter error * Set all dashboards to published on migration * Migration proper line spacing * Fix migration to work with postgres * UPDATE statement works with postgresql and sqlite hopefully * Update wording to kick off travis --- .../dashboard/components/Header_spec.jsx | 18 +++ .../src/dashboard/actions/dashboardState.js | 26 ++++ .../src/dashboard/components/Header.jsx | 13 ++ .../dashboard/components/PublishedStatus.jsx | 121 ++++++++++++++++++ .../dashboard/containers/DashboardHeader.jsx | 3 + .../src/dashboard/reducers/dashboardState.js | 4 + .../src/dashboard/reducers/getInitialState.js | 1 + .../src/dashboard/stylesheets/dashboard.less | 5 + .../assets/src/dashboard/util/propShapes.jsx | 1 + ...bdd4_add_published_column_to_dashboards.py | 41 ++++++ superset/models/core.py | 2 + superset/views/core.py | 103 ++++++++++++--- tests/dashboard_tests.py | 118 ++++++++++++++++- 13 files changed, 432 insertions(+), 24 deletions(-) create mode 100644 superset/assets/src/dashboard/components/PublishedStatus.jsx create mode 100644 superset/migrations/versions/d6ffdf31bdd4_add_published_column_to_dashboards.py mode change 100644 => 100755 superset/models/core.py diff --git a/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx index 69e57da8d..45f987414 100644 --- a/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx @@ -21,6 +21,7 @@ import { shallow } from 'enzyme'; import Header from '../../../../src/dashboard/components/Header'; import EditableTitle from '../../../../src/components/EditableTitle'; import FaveStar from '../../../../src/components/FaveStar'; +import PublishedStatus from '../../../../src/dashboard/components/PublishedStatus'; import HeaderActionsDropdown from '../../../../src/dashboard/components/HeaderActionsDropdown'; import Button from '../../../../src/components/Button'; import UndoRedoKeylisteners from '../../../../src/dashboard/components/UndoRedoKeylisteners'; @@ -43,6 +44,8 @@ describe('Header', () => { fetchFaveStar: () => {}, fetchCharts: () => {}, saveFaveStar: () => {}, + savePublished: () => {}, + isPublished: () => {}, startPeriodicRender: () => {}, updateDashboardTitle: () => {}, editMode: false, @@ -78,6 +81,11 @@ describe('Header', () => { expect(wrapper.find(EditableTitle)).toHaveLength(1); }); + it('should render the PublishedStatus', () => { + const wrapper = setup(overrideProps); + expect(wrapper.find(PublishedStatus)).toHaveLength(1); + }); + it('should render the FaveStar', () => { const wrapper = setup(overrideProps); expect(wrapper.find(FaveStar)).toHaveLength(1); @@ -110,6 +118,11 @@ describe('Header', () => { expect(wrapper.find(EditableTitle)).toHaveLength(1); }); + it('should render the PublishedStatus', () => { + const wrapper = setup(overrideProps); + expect(wrapper.find(PublishedStatus)).toHaveLength(1); + }); + it('should render the FaveStar', () => { const wrapper = setup(overrideProps); expect(wrapper.find(FaveStar)).toHaveLength(1); @@ -147,6 +160,11 @@ describe('Header', () => { expect(wrapper.find(FaveStar)).toHaveLength(1); }); + it('should render the PublishedStatus', () => { + const wrapper = setup(overrideProps); + expect(wrapper.find(PublishedStatus)).toHaveLength(1); + }); + it('should render the HeaderActionsDropdown', () => { const wrapper = setup(overrideProps); expect(wrapper.find(HeaderActionsDropdown)).toHaveLength(1); diff --git a/superset/assets/src/dashboard/actions/dashboardState.js b/superset/assets/src/dashboard/actions/dashboardState.js index d3a97cd45..3318c33eb 100644 --- a/superset/assets/src/dashboard/actions/dashboardState.js +++ b/superset/assets/src/dashboard/actions/dashboardState.js @@ -99,6 +99,32 @@ export function saveFaveStar(id, isStarred) { }; } +export const TOGGLE_PUBLISHED = 'TOGGLE_PUBLISHED'; +export function togglePublished(isPublished) { + return { type: TOGGLE_PUBLISHED, isPublished }; +} + +export function savePublished(id, isPublished) { + return function savePublishedThunk(dispatch) { + return SupersetClient.post({ + endpoint: `/superset/dashboard/${id}/published/`, + postPayload: { published: isPublished }, + }) + .then(() => { + const nowPublished = isPublished ? 'published' : 'hidden'; + dispatch(addSuccessToast(t(`This dashboard is now ${nowPublished}`))); + dispatch(togglePublished(isPublished)); + }) + .catch(() => { + dispatch( + addDangerToast( + t('You do not have permissions to edit this dashboard.'), + ), + ); + }); + }; +} + export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE'; export function toggleExpandSlice(sliceId) { return { type: TOGGLE_EXPAND_SLICE, sliceId }; diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx index 8f3f831e3..d8bacf825 100644 --- a/superset/assets/src/dashboard/components/Header.jsx +++ b/superset/assets/src/dashboard/components/Header.jsx @@ -26,6 +26,7 @@ import HeaderActionsDropdown from './HeaderActionsDropdown'; import EditableTitle from '../../components/EditableTitle'; import Button from '../../components/Button'; import FaveStar from '../../components/FaveStar'; +import PublishedStatus from './PublishedStatus'; import UndoRedoKeylisteners from './UndoRedoKeylisteners'; import { chartPropShape } from '../util/propShapes'; @@ -57,12 +58,14 @@ const propTypes = { colorNamespace: PropTypes.string, colorScheme: PropTypes.string, isStarred: PropTypes.bool.isRequired, + isPublished: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, onSave: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, fetchFaveStar: PropTypes.func.isRequired, fetchCharts: PropTypes.func.isRequired, saveFaveStar: PropTypes.func.isRequired, + savePublished: PropTypes.func.isRequired, startPeriodicRender: PropTypes.func.isRequired, updateDashboardTitle: PropTypes.func.isRequired, editMode: PropTypes.bool.isRequired, @@ -272,6 +275,7 @@ class Header extends React.PureComponent { onSave, updateCss, editMode, + isPublished, builderPaneType, dashboardInfo, hasUnsavedChanges, @@ -293,6 +297,15 @@ class Header extends React.PureComponent { onSaveTitle={this.handleChangeText} showTooltip={false} /> + + + + + + ); + } + return ( + +
Draft
+
+ ); + } + + // Show the published badge for the owner of the dashboard to toggle + else if (this.props.canEdit && this.props.canSave) { + return ( + + + + ); + } + + // Don't show anything if one doesn't own the dashboard and it is published + return null; + } +} + +PublishedStatus.propTypes = propTypes; diff --git a/superset/assets/src/dashboard/containers/DashboardHeader.jsx b/superset/assets/src/dashboard/containers/DashboardHeader.jsx index 05e90fb92..614ca178f 100644 --- a/superset/assets/src/dashboard/containers/DashboardHeader.jsx +++ b/superset/assets/src/dashboard/containers/DashboardHeader.jsx @@ -27,6 +27,7 @@ import { showBuilderPane, fetchFaveStar, saveFaveStar, + savePublished, fetchCharts, startPeriodicRender, updateCss, @@ -76,6 +77,7 @@ function mapStateToProps({ charts, userId: dashboardInfo.userId, isStarred: !!dashboardState.isStarred, + isPublished: !!dashboardState.isPublished, isLoading: isDashboardLoading(charts), hasUnsavedChanges: !!dashboardState.hasUnsavedChanges, maxUndoHistoryExceeded: !!dashboardState.maxUndoHistoryExceeded, @@ -96,6 +98,7 @@ function mapDispatchToProps(dispatch) { showBuilderPane, fetchFaveStar, saveFaveStar, + savePublished, fetchCharts, startPeriodicRender, updateDashboardTitle, diff --git a/superset/assets/src/dashboard/reducers/dashboardState.js b/superset/assets/src/dashboard/reducers/dashboardState.js index 830e2a751..4a12ce05f 100644 --- a/superset/assets/src/dashboard/reducers/dashboardState.js +++ b/superset/assets/src/dashboard/reducers/dashboardState.js @@ -30,6 +30,7 @@ import { SHOW_BUILDER_PANE, TOGGLE_EXPAND_SLICE, TOGGLE_FAVE_STAR, + TOGGLE_PUBLISHED, UPDATE_CSS, SET_REFRESH_FREQUENCY, } from '../actions/dashboardState'; @@ -71,6 +72,9 @@ export default function dashboardStateReducer(state = {}, action) { [TOGGLE_FAVE_STAR]() { return { ...state, isStarred: action.isStarred }; }, + [TOGGLE_PUBLISHED]() { + return { ...state, isPublished: action.isPublished }; + }, [SET_EDIT_MODE]() { return { ...state, diff --git a/superset/assets/src/dashboard/reducers/getInitialState.js b/superset/assets/src/dashboard/reducers/getInitialState.js index 8113356a4..14b7937cb 100644 --- a/superset/assets/src/dashboard/reducers/getInitialState.js +++ b/superset/assets/src/dashboard/reducers/getInitialState.js @@ -217,6 +217,7 @@ export default function(bootstrapData) { colorNamespace: dashboard.metadata.color_namespace, colorScheme: dashboard.metadata.color_scheme, editMode: dashboard.dash_edit_perm && editMode, + isPublished: dashboard.published, builderPaneType: dashboard.dash_edit_perm && editMode ? BUILDER_PANE_TYPE.ADD_COMPONENTS diff --git a/superset/assets/src/dashboard/stylesheets/dashboard.less b/superset/assets/src/dashboard/stylesheets/dashboard.less index 16541db1e..c37d5e593 100644 --- a/superset/assets/src/dashboard/stylesheets/dashboard.less +++ b/superset/assets/src/dashboard/stylesheets/dashboard.less @@ -214,6 +214,11 @@ body { position: relative; margin-left: 8px; } + + .publish { + position: relative; + margin-left: 8px; + } } .ace_gutter { diff --git a/superset/assets/src/dashboard/util/propShapes.jsx b/superset/assets/src/dashboard/util/propShapes.jsx index c50ffc6a0..099feb3bf 100644 --- a/superset/assets/src/dashboard/util/propShapes.jsx +++ b/superset/assets/src/dashboard/util/propShapes.jsx @@ -72,6 +72,7 @@ export const dashboardStatePropShape = PropTypes.shape({ filters: PropTypes.object.isRequired, expandedSlices: PropTypes.object, editMode: PropTypes.bool, + isPublished: PropTypes.bool.isRequired, builderPaneType: PropTypes.string.isRequired, colorNamespace: PropTypes.string, colorScheme: PropTypes.string, diff --git a/superset/migrations/versions/d6ffdf31bdd4_add_published_column_to_dashboards.py b/superset/migrations/versions/d6ffdf31bdd4_add_published_column_to_dashboards.py new file mode 100644 index 000000000..75f3bc7b3 --- /dev/null +++ b/superset/migrations/versions/d6ffdf31bdd4_add_published_column_to_dashboards.py @@ -0,0 +1,41 @@ +# 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. +"""Add published column to dashboards + +Revision ID: d6ffdf31bdd4 +Revises: 45e7da7cfeba +Create Date: 2018-03-30 14:00:44.929483 + +""" + +# revision identifiers, used by Alembic. +revision = "d6ffdf31bdd4" +down_revision = "d7c1a0d6f2da" + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + with op.batch_alter_table("dashboards") as batch_op: + batch_op.add_column(sa.Column("published", sa.Boolean(), nullable=True)) + op.execute("UPDATE dashboards SET published='1'") + + +def downgrade(): + with op.batch_alter_table("dashboards") as batch_op: + batch_op.drop_column("published") diff --git a/superset/models/core.py b/superset/models/core.py old mode 100644 new mode 100755 index f5fd94d53..0e5d1dbbb --- a/superset/models/core.py +++ b/superset/models/core.py @@ -420,6 +420,7 @@ class Dashboard(Model, AuditMixinNullable, ImportMixin): slug = Column(String(255), unique=True) slices = relationship("Slice", secondary=dashboard_slices, backref="dashboards") owners = relationship(security_manager.user_model, secondary=dashboard_user) + published = Column(Boolean, default=False) export_fields = ( "dashboard_title", @@ -484,6 +485,7 @@ class Dashboard(Model, AuditMixinNullable, ImportMixin): "metadata": self.params_dict, "css": self.css, "dashboard_title": self.dashboard_title, + "published": self.published, "slug": self.slug, "slices": [slc.data for slc in self.slices], "position_json": positions, diff --git a/superset/views/core.py b/superset/views/core.py index 923e6b48d..749812f42 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -40,6 +40,7 @@ from flask_appbuilder import expose, SimpleFormView from flask_appbuilder.actions import action from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_appbuilder.security.decorators import has_access, has_access_api +from flask_appbuilder.security.sqla import models as ab_models from flask_babel import gettext as __ from flask_babel import lazy_gettext as _ import pandas as pd @@ -90,6 +91,7 @@ from .base import ( DeleteMixin, generate_download_headers, get_error_msg, + get_user_roles, handle_api_exception, json_error_response, json_success, @@ -209,36 +211,59 @@ class DatabaseFilter(SupersetFilter): class DashboardFilter(SupersetFilter): """ - List dashboards for which users have access to at least one slice or are owners. + List dashboards with the following criteria: + 1. Those which the user owns + 2. Those which the user has favorited + 3. Those which have been published (if they have access to at least one slice) + + If the user is an admin show them all dashboards. + This means they do not get curation but can still sort by "published" + if they wish to see those dashboards which are published first """ def apply(self, query, func): # noqa - if security_manager.all_datasource_access(): - return query + Dash = models.Dashboard + User = ab_models.User Slice = models.Slice # noqa - Dash = models.Dashboard # noqa - User = security_manager.user_model - # TODO(bogdan): add `schema_access` support here + Favorites = models.FavStar + + user_roles = [role.name.lower() for role in list(self.get_user_roles())] + if "admin" in user_roles: + return query + datasource_perms = self.get_view_menus("datasource_access") - slice_ids_qry = db.session.query(Slice.id).filter( - Slice.perm.in_(datasource_perms) + all_datasource_access = security_manager.all_datasource_access() + published_dash_query = ( + db.session.query(Dash.id) + .join(Dash.slices) + .filter( + and_( + Dash.published == True, # noqa + or_(Slice.perm.in_(datasource_perms), all_datasource_access), + ) + ) ) - owner_ids_qry = ( + + users_favorite_dash_query = db.session.query(Favorites.obj_id).filter( + and_( + Favorites.user_id == User.get_user_id(), + Favorites.class_name == "Dashboard", + ) + ) + owner_ids_query = ( db.session.query(Dash.id) .join(Dash.owners) .filter(User.id == User.get_user_id()) ) + query = query.filter( or_( - Dash.id.in_( - db.session.query(Dash.id) - .distinct() - .join(Dash.slices) - .filter(Slice.id.in_(slice_ids_qry)) - ), - Dash.id.in_(owner_ids_qry), + Dash.id.in_(owner_ids_query), + Dash.id.in_(published_dash_query), + Dash.id.in_(users_favorite_dash_query), ) ) + return query @@ -759,8 +784,8 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa add_title = _("Add Dashboard") edit_title = _("Edit Dashboard") - list_columns = ["dashboard_link", "creator", "modified"] - order_columns = ["modified"] + list_columns = ["dashboard_link", "creator", "published", "modified"] + order_columns = ["modified", "published"] edit_columns = [ "dashboard_title", "slug", @@ -768,9 +793,10 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa "position_json", "css", "json_metadata", + "published", ] show_columns = edit_columns + ["table_names", "charts"] - search_columns = ("dashboard_title", "slug", "owners") + search_columns = ("dashboard_title", "slug", "owners", "published") add_columns = edit_columns base_order = ("changed_on", "desc") description_columns = { @@ -793,6 +819,10 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa "want to alter specific parameters." ), "owners": _("Owners is a list of users who can alter the dashboard."), + "published": _( + "Determines whether or not this dashboard is " + "visible in the list of all dashboards" + ), } base_filters = [["slice", DashboardFilter, lambda: []]] label_columns = { @@ -2385,6 +2415,41 @@ class Superset(BaseSupersetView): session.commit() return json_success(json.dumps({"count": count})) + @api + @has_access_api + @expose("/dashboard//published/", methods=("GET", "POST")) + def publish(self, dashboard_id): + """Gets and toggles published status on dashboards""" + session = db.session() + Dashboard = models.Dashboard # noqa + Role = ab_models.Role + dash = ( + session.query(Dashboard).filter(Dashboard.id == dashboard_id).one_or_none() + ) + admin_role = session.query(Role).filter(Role.name == "Admin").one_or_none() + + if request.method == "GET": + if dash: + return json_success(json.dumps({"published": dash.published})) + else: + return json_error_response( + "ERROR: cannot find dashboard {0}".format(dashboard_id), status=404 + ) + + else: + edit_perm = is_owner(dash, g.user) or admin_role in get_user_roles() + if not edit_perm: + return json_error_response( + 'ERROR: "{0}" cannot alter dashboard "{1}"'.format( + g.user.username, dash.dashboard_title + ), + status=403, + ) + + dash.published = str(request.form["published"]).lower() == "true" + session.commit() + return json_success(json.dumps({"published": dash.published})) + @has_access @expose("/dashboard//") def dashboard(self, dashboard_id): diff --git a/tests/dashboard_tests.py b/tests/dashboard_tests.py index 39a707874..3d911b4a2 100644 --- a/tests/dashboard_tests.py +++ b/tests/dashboard_tests.py @@ -301,6 +301,14 @@ class DashboardTests(SupersetTestCase): def test_public_user_dashboard_access(self): table = db.session.query(SqlaTable).filter_by(table_name="birth_names").one() + + # Make the births dash published so it can be seen + births_dash = db.session.query(models.Dashboard).filter_by(slug="births").one() + births_dash.published = True + + db.session.merge(births_dash) + db.session.commit() + # Try access before adding appropriate permissions. self.revoke_public_access_to_table(table) self.logout() @@ -379,15 +387,115 @@ class DashboardTests(SupersetTestCase): resp = self.get_resp("/dashboard/list/") self.assertNotIn("/superset/dashboard/empty_dashboard/", resp) - dash = ( - db.session.query(models.Dashboard).filter_by(slug="empty_dashboard").first() + def test_users_can_view_published_dashboard(self): + table = db.session.query(SqlaTable).filter_by(table_name="energy_usage").one() + # get a slice from the allowed table + slice = ( + db.session.query(models.Slice).filter_by(slice_name="Energy Sankey").one() ) - dash.owners = [gamma_user] - db.session.merge(dash) + + self.grant_public_access_to_table(table) + + # Create a published and hidden dashboard and add them to the database + published_dash = models.Dashboard() + published_dash.dashboard_title = "Published Dashboard" + published_dash.slug = "published_dash" + published_dash.slices = [slice] + published_dash.published = True + + hidden_dash = models.Dashboard() + hidden_dash.dashboard_title = "Hidden Dashboard" + hidden_dash.slug = "hidden_dash" + hidden_dash.slices = [slice] + hidden_dash.published = False + + db.session.merge(published_dash) + db.session.merge(hidden_dash) db.session.commit() resp = self.get_resp("/dashboard/list/") - self.assertIn("/superset/dashboard/empty_dashboard/", resp) + self.assertNotIn("/superset/dashboard/hidden_dash/", resp) + self.assertIn("/superset/dashboard/published_dash/", resp) + + def test_users_can_view_own_dashboard(self): + user = security_manager.find_user("gamma") + + # Create one dashboard I own and another that I don't + dash = models.Dashboard() + dash.dashboard_title = "My Dashboard" + dash.slug = "my_dash" + dash.owners = [user] + dash.slices = [] + + hidden_dash = models.Dashboard() + hidden_dash.dashboard_title = "Not My Dashboard" + hidden_dash.slug = "not_my_dash" + hidden_dash.slices = [] + hidden_dash.owners = [] + + db.session.merge(dash) + db.session.merge(hidden_dash) + db.session.commit() + + self.login(user.username) + + resp = self.get_resp("/dashboard/list/") + self.assertIn("/superset/dashboard/my_dash/", resp) + self.assertNotIn("/superset/dashboard/not_my_dash/", resp) + + def test_users_can_view_favorited_dashboards(self): + user = security_manager.find_user("gamma") + + favorite_dash = models.Dashboard() + favorite_dash.dashboard_title = "My Favorite Dashboard" + favorite_dash.slug = "my_favorite_dash" + + regular_dash = models.Dashboard() + regular_dash.dashboard_title = "A Plain Ol Dashboard" + regular_dash.slug = "regular_dash" + + db.session.merge(favorite_dash) + db.session.merge(regular_dash) + db.session.commit() + + dash = ( + db.session.query(models.Dashboard) + .filter_by(slug="my_favorite_dash") + .first() + ) + + favorites = models.FavStar() + favorites.obj_id = dash.id + favorites.class_name = "Dashboard" + favorites.user_id = user.id + + db.session.merge(favorites) + db.session.commit() + + self.login(user.username) + + resp = self.get_resp("/dashboard/list/") + self.assertIn("/superset/dashboard/my_favorite_dash/", resp) + + def test_user_can_not_view_unpublished_dash(self): + admin_user = security_manager.find_user("admin") + gamma_user = security_manager.find_user("gamma") + slug = "admin_owned_unpublished_dash" + + # Create a dashboard owned by admin and unpublished + dash = models.Dashboard() + dash.dashboard_title = "My Dashboard" + dash.slug = slug + dash.owners = [admin_user] + dash.slices = [] + dash.published = False + db.session.merge(dash) + db.session.commit() + + # list dashboards as a gamma user + self.login(gamma_user.username) + resp = self.get_resp("/dashboard/list/") + self.assertNotIn(f"/superset/dashboard/{slug}/", resp) if __name__ == "__main__":