superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From maximebeauche...@apache.org
Subject [incubator-superset] branch master updated: Add "Published" feature to dashboards (#4725)
Date Thu, 11 Jul 2019 06:14:29 GMT
This is an automated email from the ASF dual-hosted git repository.

maximebeauchemin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 97ffb76  Add "Published" feature to dashboards (#4725)
97ffb76 is described below

commit 97ffb762d0216e745fb1d946b4b1a4beeb66bfdf
Author: Tresdon Jones <tresdonj@gmail.com>
AuthorDate: Thu Jul 11 00:14:13 2019 -0600

    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 +++
 .../assets/src/dashboard/actions/dashboardState.js |  26 +++++
 .../assets/src/dashboard/components/Header.jsx     |  13 +++
 .../src/dashboard/components/PublishedStatus.jsx   | 121 +++++++++++++++++++++
 .../src/dashboard/containers/DashboardHeader.jsx   |   3 +
 .../src/dashboard/reducers/dashboardState.js       |   4 +
 .../src/dashboard/reducers/getInitialState.js      |   1 +
 .../src/dashboard/stylesheets/dashboard.less       |   5 +
 superset/assets/src/dashboard/util/propShapes.jsx  |   1 +
 ...fdf31bdd4_add_published_column_to_dashboards.py |  41 +++++++
 superset/models/core.py                            |   2 +
 superset/views/core.py                             | 103 ++++++++++++++----
 tests/dashboard_tests.py                           | 114 ++++++++++++++++++-
 13 files changed, 430 insertions(+), 22 deletions(-)

diff --git a/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Header_spec.jsx
index 69e57da..45f9874 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 d3a97cd..3318c33 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 8f3f831..d8bacf8 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}
           />
+          <span className="publish">
+            <PublishedStatus
+              dashboardId={dashboardInfo.id}
+              isPublished={isPublished}
+              savePublished={this.props.savePublished}
+              canEdit={userCanEdit}
+              canSave={userCanSaveAs}
+            />
+          </span>
           <span className="favstar">
             <FaveStar
               itemId={dashboardInfo.id}
diff --git a/superset/assets/src/dashboard/components/PublishedStatus.jsx b/superset/assets/src/dashboard/components/PublishedStatus.jsx
new file mode 100644
index 0000000..cbf86d9
--- /dev/null
+++ b/superset/assets/src/dashboard/components/PublishedStatus.jsx
@@ -0,0 +1,121 @@
+/**
+ * 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.
+ */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { t } from '@superset-ui/translation';
+import TooltipWrapper from '../../components/TooltipWrapper';
+
+const propTypes = {
+  dashboardId: PropTypes.number.isRequired,
+  isPublished: PropTypes.bool.isRequired,
+  savePublished: PropTypes.func.isRequired,
+  canEdit: PropTypes.bool.isRequired,
+  canSave: PropTypes.bool.isRequired,
+};
+
+const draftButtonTooltip = t(
+  'This dashboard is not published, it will not show up in the list of dashboards. ' +
+    'Click here to publish this dashboard.',
+);
+
+const draftDivTooltip = t(
+  'This dashboard is not published which means it will not show up in the list of dashboards.'
+
+    ' Favorite it to see it there or access it by using the URL directly.',
+);
+
+const publishedTooltip = t(
+  'This dashboard is published. Click to make it a draft.',
+);
+
+const divStyle = {
+  border: '1px dotted black',
+  backgroundColor: '#F9F9F9',
+  padding: '3px 7px 3px 7px',
+  fontFamily: 'Monospace',
+  fontSize: '16px',
+};
+
+export default class PublishedStatus extends React.Component {
+  componentDidMount() {
+    this.togglePublished = this.togglePublished.bind(this);
+  }
+
+  togglePublished() {
+    this.props.savePublished(this.props.dashboardId, !this.props.isPublished);
+  }
+
+  render() {
+    // Show everybody the draft badge
+    if (!this.props.isPublished) {
+      // if they can edit the dash, make the badge a button
+      if (this.props.canEdit && this.props.canSave) {
+        return (
+          <TooltipWrapper
+            label="Unpublished Dashboard"
+            placement="bottom"
+            tooltip={draftButtonTooltip}
+          >
+            <button
+              style={divStyle}
+              onClick={() => {
+                this.togglePublished();
+              }}
+            >
+              Draft
+            </button>
+          </TooltipWrapper>
+        );
+      }
+      return (
+        <TooltipWrapper
+          label="Unpublished Dashboard"
+          placement="bottom"
+          tooltip={draftDivTooltip}
+        >
+          <div style={divStyle}>Draft</div>
+        </TooltipWrapper>
+      );
+    }
+
+    // Show the published badge for the owner of the dashboard to toggle
+    else if (this.props.canEdit && this.props.canSave) {
+      return (
+        <TooltipWrapper
+          label="Published Dashboard"
+          placement="bottom"
+          tooltip={publishedTooltip}
+        >
+          <button
+            style={divStyle}
+            onClick={() => {
+              this.togglePublished();
+            }}
+          >
+            Published
+          </button>
+        </TooltipWrapper>
+      );
+    }
+
+    // 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 05e90fb..614ca17 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 830e2a7..4a12ce0 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 8113356..14b7937 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 16541db..c37d5e5 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 c50ffc6..099feb3 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 0000000..75f3bc7
--- /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 f5fd94d..0e5d1db
--- 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 923e6b4..749812f 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/<dashboard_id>/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/<dashboard_id>/")
     def dashboard(self, dashboard_id):
diff --git a/tests/dashboard_tests.py b/tests/dashboard_tests.py
index 39a7078..3d911b4 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)
 
+    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()
+        )
+
+        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.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="empty_dashboard").first()
+            db.session.query(models.Dashboard)
+            .filter_by(slug="my_favorite_dash")
+            .first()
         )
-        dash.owners = [gamma_user]
+
+        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.assertIn("/superset/dashboard/empty_dashboard/", resp)
+        self.assertNotIn(f"/superset/dashboard/{slug}/", resp)
 
 
 if __name__ == "__main__":


Mime
View raw message