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
This commit is contained in:
Tresdon Jones 2019-07-11 00:14:13 -06:00 committed by Maxime Beauchemin
parent 8d81c3049a
commit 97ffb762d0
13 changed files with 432 additions and 24 deletions

View File

@ -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);

View File

@ -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 };

View File

@ -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}

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -214,6 +214,11 @@ body {
position: relative;
margin-left: 8px;
}
.publish {
position: relative;
margin-left: 8px;
}
}
.ace_gutter {

View File

@ -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,

View File

@ -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")

2
superset/models/core.py Normal file → Executable file
View File

@ -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,

View File

@ -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):

View File

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