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