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__":