diff --git a/requirements.txt b/requirements.txt
index 3b88de610364..0789c1b370b5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -54,6 +54,7 @@ numpy==1.15.2 # via pandas
pandas==0.23.4
parsedatetime==2.0.0
pathlib2==2.3.0
+pillow==5.4.1
polyline==1.3.2
prison==0.1.0 # via flask-appbuilder
py==1.7.0 # via retry
diff --git a/setup.py b/setup.py
index fc91ea484d59..a4d963026957 100644
--- a/setup.py
+++ b/setup.py
@@ -92,6 +92,7 @@ def get_git_sha():
'pandas>=0.18.0, <0.24.0', # `pandas`>=0.24.0 changes datetimelike API
'parsedatetime',
'pathlib2',
+ 'pillow==5.4.1',
'polyline',
'pydruid>=0.5.2',
'python-dateutil',
diff --git a/superset/__init__.py b/superset/__init__.py
index 6971dc9d4c8a..6f37478b45e4 100644
--- a/superset/__init__.py
+++ b/superset/__init__.py
@@ -115,6 +115,10 @@ def get_manifest():
if conf.get('SILENCE_FAB'):
logging.getLogger('flask_appbuilder').setLevel(logging.ERROR)
+logging.getLogger('urllib3').setLevel(logging.ERROR)
+logging.getLogger('selenium').setLevel(logging.ERROR)
+logging.getLogger('PIL').setLevel(logging.ERROR)
+
if app.debug:
app.logger.setLevel(logging.DEBUG) # pylint: disable=no-member
else:
@@ -135,6 +139,7 @@ def get_manifest():
cache = setup_cache(app, conf.get('CACHE_CONFIG'))
tables_cache = setup_cache(app, conf.get('TABLE_NAMES_CACHE_CONFIG'))
+thumbnail_cache = setup_cache(app, conf.get('THUMBNAIL_CACHE_CONFIG'))
migrate = Migrate(app, db, directory=APP_DIR + '/migrations')
diff --git a/superset/assets/images/no-image.png b/superset/assets/images/no-image.png
new file mode 100644
index 000000000000..0371c4733d49
Binary files /dev/null and b/superset/assets/images/no-image.png differ
diff --git a/superset/assets/package-lock.json b/superset/assets/package-lock.json
index da886a0c95d1..6047df5d0417 100644
--- a/superset/assets/package-lock.json
+++ b/superset/assets/package-lock.json
@@ -4953,6 +4953,11 @@
"integrity": "sha1-3DQxT05nkxgJP8dgJyUl+UvyXBY=",
"dev": true
},
+ "batch-processor": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/batch-processor/-/batch-processor-1.0.0.tgz",
+ "integrity": "sha1-dclcMrdI4IUNEMKxaPa9vpiRrOg="
+ },
"bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
@@ -5470,6 +5475,11 @@
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
+ "chain-function": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.1.tgz",
+ "integrity": "sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg=="
+ },
"chalk": {
"version": "1.1.3",
"resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@@ -7375,6 +7385,14 @@
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.1.4.tgz",
"integrity": "sha512-ttRjmPD5oaTtXOoxhFp9aZvMB14kBjapYaiBuzBB1elOgSLU9P2Ev86G2OClBg+uspUXERsIzXKpUWweH2K4Xg=="
},
+ "easy-css-transform-builder": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/easy-css-transform-builder/-/easy-css-transform-builder-0.0.2.tgz",
+ "integrity": "sha1-pUFmenkZ4X9n2CsR08tV/dVDMCI=",
+ "requires": {
+ "invariant": "^2.2.2"
+ }
+ },
"ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
@@ -7402,6 +7420,14 @@
"integrity": "sha512-En051LMzMl3/asMWGZEtU808HOoVWIpmmZx1Ep8N+TT9e7z/X8RcLeBU2kLSNLGQ+5SuKELzMx+MVuTBXk6Q9w==",
"dev": true
},
+ "element-resize-detector": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/element-resize-detector/-/element-resize-detector-1.1.15.tgz",
+ "integrity": "sha512-16/5avDegXlUxytGgaumhjyQoM6hpp5j3+L79sYq5hlXfTNRy5WMMuTVWkZU3egp/CokCmTmvf18P3KeB57Iog==",
+ "requires": {
+ "batch-processor": "^1.0.0"
+ }
+ },
"elliptic": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz",
@@ -8108,6 +8134,11 @@
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
"dev": true
},
+ "ev-emitter": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ev-emitter/-/ev-emitter-1.1.1.tgz",
+ "integrity": "sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q=="
+ },
"eventemitter3": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.0.tgz",
@@ -8160,6 +8191,11 @@
"strip-eof": "^1.0.0"
}
},
+ "exenv": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
+ "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50="
+ },
"exif-parser": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz",
@@ -10718,6 +10754,14 @@
"dev": true,
"optional": true
},
+ "imagesloaded": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/imagesloaded/-/imagesloaded-4.1.4.tgz",
+ "integrity": "sha512-ltiBVcYpc/TYTF5nolkMNsnREHW+ICvfQ3Yla2Sgr71YFwQ86bDwV9hgpFhFtrGPuwEx5+LqOHIrdXBdoWwwsA==",
+ "requires": {
+ "ev-emitter": "^1.0.0"
+ }
+ },
"immutable": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
@@ -17685,6 +17729,17 @@
"js-search": "^1.3.1"
}
},
+ "react-sizeme": {
+ "version": "2.6.7",
+ "resolved": "https://registry.npmjs.org/react-sizeme/-/react-sizeme-2.6.7.tgz",
+ "integrity": "sha512-xCjPoBP5jmeW58TxIkcviMZqabZis7tTvDFWf0/Wa5XCgVWQTIe74NQBes2N1Kmp64GRLkpm60BaP0kk+v8aCQ==",
+ "requires": {
+ "element-resize-detector": "^1.1.15",
+ "invariant": "^2.2.4",
+ "shallowequal": "^1.1.0",
+ "throttle-debounce": "^2.1.0"
+ }
+ },
"react-sortable-hoc": {
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-0.8.4.tgz",
@@ -17714,6 +17769,36 @@
"react-style-proptype": "^3.0.0"
}
},
+ "react-stack-grid": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/react-stack-grid/-/react-stack-grid-0.7.1.tgz",
+ "integrity": "sha512-Fw7qMt5Rd9wQpNCnvK4Gi+ry/nL5rKfzP2hGsw5/DZxArEMk60VoDLy68Uskq09l6wk7qb2w7P2+lNzSd9mYEw==",
+ "requires": {
+ "easy-css-transform-builder": "^0.0.2",
+ "exenv": "^1.2.1",
+ "imagesloaded": "^4.1.1",
+ "inline-style-prefixer": "^3.0.6",
+ "invariant": "^2.2.2",
+ "prop-types": "^15.5.10",
+ "react-sizeme": "^2.2.0",
+ "react-transition-group": "^1.2.0",
+ "shallowequal": "^1.0.1"
+ },
+ "dependencies": {
+ "react-transition-group": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-1.2.1.tgz",
+ "integrity": "sha512-CWaL3laCmgAFdxdKbhhps+c0HRGF4c+hdM4H23+FI1QBNUyx/AMeIJGWorehPNSaKnQNOAxL7PQmqMu78CDj3Q==",
+ "requires": {
+ "chain-function": "^1.0.0",
+ "dom-helpers": "^3.2.0",
+ "loose-envify": "^1.3.1",
+ "prop-types": "^15.5.6",
+ "warning": "^3.0.0"
+ }
+ }
+ }
+ },
"react-sticky": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/react-sticky/-/react-sticky-6.0.3.tgz",
@@ -20271,6 +20356,11 @@
"integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=",
"dev": true
},
+ "throttle-debounce": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.1.0.tgz",
+ "integrity": "sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg=="
+ },
"through": {
"version": "2.3.8",
"resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz",
diff --git a/superset/assets/package.json b/superset/assets/package.json
index 52e82b12e01a..cccc2de63cac 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -127,6 +127,7 @@
"react-select-fast-filter-options": "^0.2.1",
"react-sortable-hoc": "^0.8.3",
"react-split": "^2.0.4",
+ "react-stack-grid": "^0.7.1",
"react-sticky": "^6.0.2",
"react-syntax-highlighter": "^7.0.4",
"react-transition-group": "^2.5.3",
diff --git a/superset/assets/spec/javascripts/welcome/DashboardTable_spec.jsx b/superset/assets/spec/javascripts/welcome/DashboardTable_spec.jsx
index e989f9a207ae..28c1adda9e2c 100644
--- a/superset/assets/spec/javascripts/welcome/DashboardTable_spec.jsx
+++ b/superset/assets/spec/javascripts/welcome/DashboardTable_spec.jsx
@@ -23,7 +23,7 @@ import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
import { Table } from 'reactable-arc';
-import DashboardTable from '../../../src/welcome/DashboardTable';
+import DashboardCardTable from '../../../src/welcome/DashboardCardTable';
import Loading from '../../../src/components/Loading';
// store needed for withToasts(TableLoader)
@@ -39,10 +39,10 @@ fetchMock.get(dashboardsEndpoint, { result: mockDashboards });
function setup() {
// use mount because data fetching is triggered on mount
- return mount( , { context: { store } });
+ return mount( , { context: { store } });
}
-describe('DashboardTable', () => {
+describe('DashboardCardTable', () => {
beforeEach(fetchMock.resetHistory);
it('renders a Loading initially', () => {
diff --git a/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx b/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx
index 8a18b299955e..a83728264b38 100644
--- a/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx
+++ b/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx
@@ -33,6 +33,6 @@ describe('Welcome', () => {
const wrapper = shallow( );
expect(wrapper.find(Tab)).toHaveLength(3);
expect(wrapper.find(Panel)).toHaveLength(3);
- expect(wrapper.find(Row)).toHaveLength(3);
+ expect(wrapper.find(Row)).toHaveLength(2);
});
});
diff --git a/superset/assets/src/components/Card.css b/superset/assets/src/components/Card.css
new file mode 100644
index 000000000000..b70e828ac75b
--- /dev/null
+++ b/superset/assets/src/components/Card.css
@@ -0,0 +1,58 @@
+/**
+ * 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.
+ */
+.card {
+ background-color: white;
+ border-radius: 5px;
+ box-shadow: 0 1px 1px 0 rgba(60,64,67,.16), 0 1px 3px 1px rgba(60,64,67,.25);
+ overflow: hidden;
+ transition: box-shadow 0.5s ease-in-out;
+}
+.card:hover {
+ box-shadow: 0 2px 2px 0 rgba(60,64,67,.30), 0 2px 4px 2px rgba(60,64,67,.55);
+}
+.card-header {
+ padding-top: 5px;
+ padding-left: 15px;
+ padding-right: 15px;
+ padding-bottom: 0px;
+}
+.card-body {
+ padding-top: 5px;
+ padding-left: 15px;
+ padding-right: 15px;
+ padding-bottom: 15px;
+}
+.card-body .text-muted{
+ color: #888;
+}
+
+.card-header .fa {
+ margin-top: 10px;
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
+.card img {
+ border-top: 1px solid #EEE;
+ border-bottom: 1px solid #EEE;
+}
+.card-title {
+ white-space: nowrap;
+ width: 100px;
+}
diff --git a/superset/assets/src/components/Card.jsx b/superset/assets/src/components/Card.jsx
new file mode 100644
index 000000000000..ea7d7e65b632
--- /dev/null
+++ b/superset/assets/src/components/Card.jsx
@@ -0,0 +1,102 @@
+/**
+ * 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 { Dropdown, Fade } from 'react-bootstrap';
+
+import ToggleWrapper from './ToggleWrapper';
+import './Card.css';
+
+const propTypes = {
+ title: PropTypes.string.isRequired,
+ body: PropTypes.node.isRequired,
+ imageSource: PropTypes.string.isRequired,
+ cardWidth: PropTypes.number,
+ imageAspectRatio: PropTypes.number,
+ dropdownMenu: PropTypes.node.isRequired,
+ onTitleClick: PropTypes.func,
+};
+const defaultProps = {
+ onTitleClick: () => {},
+ imageAspectRatio: 4/3,
+};
+
+export default class Card extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ hovered: false,
+ };
+ }
+ renderActionDropdown() {
+ return (
+
+
+
+
+
+
+ {this.props.dropdownMenu}
+ );
+ }
+ render() {
+ const {
+ body,
+ cardWidth,
+ imageAspectRatio,
+ imageSource,
+ onTitleClick,
+ title,
+ } = this.props;
+ const imgHeight = cardWidth / imageAspectRatio;
+ return (
+
this.setState({ hovered: true })}
+ onMouseOut={() => this.setState({ hovered: false })}
+ >
+
+
onTitleClick()}
+ >
+
{title}
+
+
+ {this.renderActionDropdown()}
+
+
+
onTitleClick()}>
+
+
+
+ {body}
+
+
+ );
+ }
+}
+Card.propTypes = propTypes;
+Card.defaultProps = defaultProps;
diff --git a/superset/assets/src/components/CardTable.css b/superset/assets/src/components/CardTable.css
new file mode 100644
index 000000000000..8c7fb0cb6588
--- /dev/null
+++ b/superset/assets/src/components/CardTable.css
@@ -0,0 +1,45 @@
+/**
+ * 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.
+ */
+.CardTable {
+ min-height: 500px;
+}
+.CardTable .input-search {
+ margin-top: 25px;
+ width: 300px;
+}
+.CardTable .card-table-toggle .fa {
+ margin-top: .2em;
+ margin-left: 0;
+}
+.CardTable .controls-right {
+ margin-top: 25px;
+}
+
+.table .td-thumb img {
+ float: left;
+ width: 100px;
+}
+
+.table .td-thumb {
+ padding: 0px;
+}
+
+.table .td-thumb span {
+ padding: 8px;
+}
diff --git a/superset/assets/src/components/CardTable.jsx b/superset/assets/src/components/CardTable.jsx
new file mode 100644
index 000000000000..53425580e34d
--- /dev/null
+++ b/superset/assets/src/components/CardTable.jsx
@@ -0,0 +1,128 @@
+/**
+ * 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 { Button, ButtonGroup, FormControl } from 'react-bootstrap';
+import StackGrid from 'react-stack-grid';
+import { t } from '@superset-ui/translation';
+
+import Loading from '../components/Loading';
+import withToasts from '../messageToasts/enhancers/withToasts';
+import './CardTable.css';
+
+const propTypes = {
+ renderTable: PropTypes.func.isRequired,
+ renderCards: PropTypes.func.isRequired,
+ cardWidth: PropTypes.number,
+ onSearchChange: PropTypes.func.isRequired,
+ title: PropTypes.string.isRequired,
+ addDangerToast: PropTypes.func.isRequired,
+ items: PropTypes.array,
+ loading: PropTypes.bool.isRequired,
+ showCardCount: PropTypes.number,
+ emptyMessage: PropTypes.node,
+};
+
+const defaultProps = {
+ cardWidth: 250,
+ emptyMessage: t('No data'),
+};
+
+class CardTable extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ search: '',
+ showTable: false,
+ };
+ this.onSearchChange = this.onSearchChange.bind(this);
+ }
+ onSearchChange(event) {
+ const search = event.target.value;
+ this.setState({ search });
+ this.props.onSearchChange(search);
+ }
+ renderCards() {
+ return (
+
+ {this.props.renderCards()}
+ );
+ }
+ renderList() {
+ if (this.state.showTable) {
+ return this.props.renderTable(this.props.items);
+ }
+ return this.renderCards();
+ }
+ render() {
+ const { showTable } = this.state;
+ const { loading, items, emptyMessage } = this.props;
+ if (loading) {
+ return ;
+ }
+ if (items && items.length === 0) {
+ return emptyMessage;
+ }
+ return (
+
+
+
+
{this.props.title}
+
+
+
+ this.setState({ showTable: false })}
+ >
+
+
+ this.setState({ showTable: true })}
+ >
+
+
+
+
+
+
+
+
+
+ {this.renderList()}
+
);
+ }
+}
+CardTable.propTypes = propTypes;
+export default withToasts(CardTable);
diff --git a/superset/assets/src/components/ChartCard.jsx b/superset/assets/src/components/ChartCard.jsx
new file mode 100644
index 000000000000..299f219ec007
--- /dev/null
+++ b/superset/assets/src/components/ChartCard.jsx
@@ -0,0 +1,104 @@
+/**
+ * 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 { Dropdown, MenuItem } from 'react-bootstrap';
+import { t } from '@superset-ui/translation';
+import Dialog from 'react-bootstrap-dialog';
+
+import Card from './Card.jsx';
+
+const propTypes = {
+ chart: PropTypes.object.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ cardWidth: PropTypes.number.isRequired,
+};
+
+
+export default class ChartCard extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.openChart = this.openChart.bind(this);
+ this.deleteDialog = this.deleteDialog.bind(this);
+ }
+ openChart() {
+ window.open(this.props.chart.slice_url);
+ }
+ deleteDialog() {
+ const onOk = () => {
+ this.props.onDelete(this.props.chart);
+ this.dialog.hide();
+ };
+ this.dialog.show({
+ title: t('Delete Chart'),
+ body: t('Are you sure you want to delete this chart?'),
+ actions: [
+ Dialog.CancelAction(diag => diag.hide()),
+ Dialog.DefaultAction('Ok', onOk, 'btn-danger'),
+ ],
+ bsSize: 'small',
+ onHide: (dialog) => {
+ dialog.hide();
+ },
+ });
+ }
+ renderDropdownMenu() {
+ const { chart } = this.props;
+ return (
+
+
+ {t('Edit chart metadata')}
+
+
+ {t('Delete Chart')}
+
+
+ );
+ }
+ renderCardBody() {
+ const { chart } = this.props;
+ return (
+
+ Modified {chart.changed_on_humanized}
+
+
+ {t('Created by')} {chart.created_by_name || t('N/A')}
+
+ {
+ this.dialog = el;
+ }}
+ />
+
+ );
+ }
+ render() {
+ const { chart } = this.props;
+ return (
+ );
+ }
+}
+ChartCard.propTypes = propTypes;
diff --git a/superset/assets/src/components/DashboardCard.jsx b/superset/assets/src/components/DashboardCard.jsx
new file mode 100644
index 000000000000..3f883213d00a
--- /dev/null
+++ b/superset/assets/src/components/DashboardCard.jsx
@@ -0,0 +1,103 @@
+/**
+ * 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 { Dropdown, MenuItem } from 'react-bootstrap';
+import { t } from '@superset-ui/translation';
+import Dialog from 'react-bootstrap-dialog';
+
+import Card from './Card.jsx';
+
+const propTypes = {
+ cardWidth: PropTypes.number.isRequired,
+ dashboard: PropTypes.object.isRequired,
+};
+
+
+export default class DashboardCard extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.openDashboard = this.openDashboard.bind(this);
+ this.deleteDialog = this.deleteDialog.bind(this);
+ }
+ openDashboard() {
+ window.open(this.props.dashboard.url);
+ }
+ deleteDialog() {
+ const onOk = () => {
+ this.props.onDelete(this.props.dashboard);
+ this.dialog.hide();
+ };
+ this.dialog.show({
+ title: t('Delete dashboard'),
+ body: t('Are you sure you want to delete this dashboard?'),
+ actions: [
+ Dialog.CancelAction(diag => diag.hide()),
+ Dialog.DefaultAction('Ok', onOk, 'btn-danger'),
+ ],
+ bsSize: 'small',
+ onHide: (dialog) => {
+ dialog.hide();
+ },
+ });
+ }
+ renderDropdownMenu() {
+ const { dashboard } = this.props;
+ return (
+
+
+ {t('Edit dashboard metadata')}
+
+
+ {t('Delete Dashboard')}
+
+
+ );
+ }
+ renderCardBody() {
+ const { dashboard } = this.props;
+ return (
+
+ Modified {dashboard.changed_on_humanized}
+
+
+ {t('Created by')} {dashboard.created_by_name || t('N/A')}
+
+ {
+ this.dialog = el;
+ }}
+ />
+
+ );
+ }
+ render() {
+ const { dashboard } = this.props;
+ return (
+ );
+ }
+}
+DashboardCard.propTypes = propTypes;
diff --git a/superset/assets/src/components/ToggleWrapper.jsx b/superset/assets/src/components/ToggleWrapper.jsx
new file mode 100644
index 000000000000..b1ec6ee7c53f
--- /dev/null
+++ b/superset/assets/src/components/ToggleWrapper.jsx
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+
+/**
+ * Allows any component to act as a Dropdown toggle
+ * for instance a simple .
+ * inspired from https://react-bootstrap.github.io/components/dropdowns/
+ * "Custom Dropdown Components" section
+ */
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ onClick: PropTypes.func,
+ children: PropTypes.node,
+};
+export default class ToggleWrapper extends React.Component {
+ constructor(props, context) {
+ super(props, context);
+
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick(e) {
+ e.preventDefault();
+ this.props.onClick(e);
+ }
+
+ render() {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+ToggleWrapper.propTypes = propTypes;
diff --git a/superset/assets/src/dashboard/components/MissingChart.jsx b/superset/assets/src/dashboard/components/MissingChart.jsx
index 34d3d2da8c16..65e2be37bc69 100644
--- a/superset/assets/src/dashboard/components/MissingChart.jsx
+++ b/superset/assets/src/dashboard/components/MissingChart.jsx
@@ -29,9 +29,9 @@ const propTypes = {
export default function MissingChart({ height }) {
return (
-
-
-
+
+
+
{t(
'There is no chart definition associated with this component, could it have been deleted?',
diff --git a/superset/assets/src/welcome/ChartCardTable.jsx b/superset/assets/src/welcome/ChartCardTable.jsx
new file mode 100644
index 000000000000..1acc07c87833
--- /dev/null
+++ b/superset/assets/src/welcome/ChartCardTable.jsx
@@ -0,0 +1,177 @@
+/**
+ * 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 { Table, Tr, Td, unsafe } from 'reactable-arc';
+import { SupersetClient } from '@superset-ui/connection';
+
+import withToasts from '../messageToasts/enhancers/withToasts';
+import CardTable from '../components/CardTable';
+import ChartCard from '../components/ChartCard';
+import '../../stylesheets/reactable-pagination.css';
+
+const propTypes = {
+ showTable: PropTypes.bool,
+ addDangerToast: PropTypes.func.isRequired,
+};
+
+const CHART_WIDTH = 250;
+
+class ChartCardTable extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ search: '',
+ loading: true,
+ charts: null,
+ filteredCharts: null,
+ };
+ this.renderTable = this.renderTable.bind(this);
+ this.onSearchChange = this.onSearchChange.bind(this);
+ this.renderCards = this.renderCards.bind(this);
+ this.deleteChart = this.deleteChart.bind(this);
+ }
+ componentDidMount() {
+ const endpoint = (
+ '/sliceasync/api/read?' +
+ '_oc_ChartModelViewAsync=changed_on' +
+ '&_od_ChartModelViewAsync=desc'
+ );
+ SupersetClient.get({ endpoint })
+ .then(({ json }) => {
+ const charts = json.result;
+ this.setState({
+ charts,
+ loading: false,
+ filteredCharts: charts,
+ });
+ })
+ .catch(() => {
+ this.props.addDangerToast(t('An error occurred while fetching data'));
+ this.setState({ charts: null, loading: false });
+ });
+ }
+ deleteChart(chart) {
+ const endpoint = `/chart/api/delete/${chart.id}`;
+ SupersetClient.delete({ endpoint })
+ .then(({ json }) => {
+ this.removeChart(chart);
+ })
+ .catch(() => {
+ this.props.addDangerToast(t('An error occurred while deleting the chart'));
+ });
+ }
+ onSearchChange(search) {
+ const filteredCharts = this.filterCharts(search);
+ this.setState({ search, filteredCharts });
+ }
+ removeChart(chart) {
+ const filter = o => o.id !== chart.id;
+ const charts = this.state.charts.filter(filter);
+ const filteredCharts = this.state.filteredCharts.filter(filter);
+ this.setState({ charts, filteredCharts });
+ }
+ getDatasourceName(chart) {
+ const o = chart.datasource_data_summary;
+ return o.schema ? `${o.schema}.${o.datasource_name}` : o.datasource_name;
+ }
+ filterCharts(searchText) {
+ const { charts } = this.state;
+ if (!searchText) {
+ return charts;
+ }
+ const lcaseSearchText = searchText.toLowerCase();
+ return charts.filter(o => o.slice_name.toLowerCase().indexOf(lcaseSearchText) >= 0);
+ }
+ renderDatasourceLink(chart) {
+ const o = chart.datasource_data_summary;
+ const url = o.explore_url;
+ const name = this.getDatasourceName(chart);
+ return (
+
{name}
+ );
+ }
+ renderTable() {
+ const { filteredCharts } = this.state;
+ return (
+
+ {filteredCharts.map(o => (
+
+
+
+
+ {o.slice_name}
+
+
+
+ {this.renderDatasourceLink(o)}
+
+
+ {unsafe(o.creator)}
+
+
+ {unsafe(o.modified)}
+
+ ))}
+
+ );
+ }
+ renderCards() {
+ return this.state.filteredCharts.map(chart => (
+
+ ));
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+ChartCardTable.propTypes = propTypes;
+export default withToasts(ChartCardTable);
diff --git a/superset/assets/src/welcome/DashboardCardTable.jsx b/superset/assets/src/welcome/DashboardCardTable.jsx
new file mode 100644
index 000000000000..9dc2b08344f4
--- /dev/null
+++ b/superset/assets/src/welcome/DashboardCardTable.jsx
@@ -0,0 +1,159 @@
+/**
+ * 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 { Table, Tr, Td, unsafe } from 'reactable-arc';
+import { SupersetClient } from '@superset-ui/connection';
+
+import withToasts from '../messageToasts/enhancers/withToasts';
+import CardTable from '../components/CardTable';
+import DashboardCard from '../components/DashboardCard';
+import '../../stylesheets/reactable-pagination.css';
+
+const propTypes = {
+ showTable: PropTypes.bool,
+ addDangerToast: PropTypes.func.isRequired,
+};
+
+const CARD_WIDTH = 250;
+
+class DashboardCardTable extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ search: '',
+ dashboards: null,
+ loading: true,
+ filteredDashboards: null,
+ };
+ this.renderTable = this.renderTable.bind(this);
+ this.onSearchChange = this.onSearchChange.bind(this);
+ this.onDelete = this.onDelete.bind(this);
+ this.renderCards = this.renderCards.bind(this);
+ this.deleteDashboard = this.deleteDashboard.bind(this);
+ }
+ componentDidMount() {
+ const endpoint = (
+ '/dashboardasync/api/read?' +
+ '_oc_DashboardModelViewAsync=changed_on' +
+ '&_od_DashboardModelViewAsync=desc'
+ );
+ SupersetClient.get({ endpoint })
+ .then(({ json }) => {
+ const dashboards = json.result;
+ this.setState({
+ dashboards,
+ loading: false,
+ filteredDashboards: dashboards,
+ });
+ })
+ .catch(() => {
+ this.props.addDangerToast(t('An error occurred while fetching data'));
+ this.setState({ dashboards: null, loading: false });
+ });
+ }
+ onSearchChange(search) {
+ const filteredDashboards = this.filterDashboards(search);
+ this.setState({ search, filteredDashboards });
+ }
+ onDelete(dashboard) {
+ const filter = o => o.id !== dashboard.id;
+ const dashboards = this.state.dashboards.filter(filter);
+ const filteredDashboards = this.state.filteredDashboards.filter(filter);
+ this.setState({ dashboards, filteredDashboards });
+ }
+ deleteDashboard(dashboard) {
+ const endpoint = `/dashboard/api/delete/${dashboard.id}`;
+ SupersetClient.delete({ endpoint })
+ .then(({ json }) => {
+ this.onDelete(dashboard);
+ })
+ .catch(() => {
+ this.props.addDangerToast(t('An error occurred while deleting the dashboard'));
+ });
+ }
+ filterDashboards(searchText) {
+ const { dashboards } = this.state;
+ if (!searchText) {
+ return dashboards;
+ }
+ const lcaseSearchText = searchText.toLowerCase();
+ return dashboards.filter(o => o.dashboard_title.toLowerCase().indexOf(lcaseSearchText) >= 0);
+ }
+ renderTable() {
+ return (
+
+ {this.state.filteredDashboards.map(o => (
+
+
+
+
+ {o.dashboard_title}
+
+
+
+ {unsafe(o.creator)}
+
+
+ {unsafe(o.modified)}
+
+ ))}
+
+ );
+ }
+ renderCards() {
+ return this.state.filteredDashboards.map(dashboard => (
+
+ ));
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+DashboardCardTable.propTypes = propTypes;
+export default withToasts(DashboardCardTable);
diff --git a/superset/assets/src/welcome/DashboardTable.jsx b/superset/assets/src/welcome/DashboardTable.jsx
deleted file mode 100644
index a3d689aec932..000000000000
--- a/superset/assets/src/welcome/DashboardTable.jsx
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * 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 { Table, Tr, Td, unsafe } from 'reactable-arc';
-import { SupersetClient } from '@superset-ui/connection';
-import { t } from '@superset-ui/translation';
-
-import withToasts from '../messageToasts/enhancers/withToasts';
-import Loading from '../components/Loading';
-import '../../stylesheets/reactable-pagination.css';
-
-const propTypes = {
- search: PropTypes.string,
- addDangerToast: PropTypes.func.isRequired,
-};
-
-class DashboardTable extends React.PureComponent {
- constructor(props) {
- super(props);
- this.state = {
- dashboards: [],
- };
- }
-
- componentDidMount() {
- SupersetClient.get({
- endpoint: '/dashboardasync/api/read?_oc_DashboardModelViewAsync=changed_on&_od_DashboardModelViewAsync=desc',
- })
- .then(({ json }) => {
- this.setState({ dashboards: json.result });
- })
- .catch(() => {
- this.props.addDangerToast(t('An error occurred while fethching Dashboards'));
- });
- }
-
- render() {
- if (this.state.dashboards.length > 0) {
- return (
-
- {this.state.dashboards.map(o => (
-
-
- {o.dashboard_title}
-
-
- {unsafe(o.creator)}
-
-
- {unsafe(o.modified)}
-
- ))}
-
- );
- }
-
- return
;
- }
-}
-
-DashboardTable.propTypes = propTypes;
-
-export default withToasts(DashboardTable);
diff --git a/superset/assets/src/welcome/Welcome.jsx b/superset/assets/src/welcome/Welcome.jsx
index db1a632fd5f3..a1ca7dc6c698 100644
--- a/superset/assets/src/welcome/Welcome.jsx
+++ b/superset/assets/src/welcome/Welcome.jsx
@@ -18,51 +18,33 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
-import { Panel, Row, Col, Tabs, Tab, FormControl } from 'react-bootstrap';
+import { Panel, Row, Col, Tabs, Tab } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import RecentActivity from '../profile/components/RecentActivity';
import Favorites from '../profile/components/Favorites';
-import DashboardTable from './DashboardTable';
+import DashboardCardTable from './DashboardCardTable';
+import ChartCardTable from './ChartCardTable';
const propTypes = {
user: PropTypes.object.isRequired,
};
export default class Welcome extends React.PureComponent {
- constructor(props) {
- super(props);
- this.state = {
- search: '',
- };
- this.onSearchChange = this.onSearchChange.bind(this);
- }
- onSearchChange(event) {
- this.setState({ search: event.target.value });
- }
render() {
return (
-
- {t('Dashboards')}
-
-
-
-
-
-
+
+
+
+
+
+
-
+
{t('Recently Viewed')}
@@ -71,7 +53,7 @@ export default class Welcome extends React.PureComponent {
-
+
{t('Favorites')}
diff --git a/superset/assets/stylesheets/less/cosmo/variables.less b/superset/assets/stylesheets/less/cosmo/variables.less
index 7b979c224525..dc61b52cbb13 100644
--- a/superset/assets/stylesheets/less/cosmo/variables.less
+++ b/superset/assets/stylesheets/less/cosmo/variables.less
@@ -29,8 +29,8 @@
@gray-darker: lighten(@gray-base, 13.5%);
@gray-dark: lighten(@gray-base, 20%);
@gray: lighten(@gray-base, 33.5%);
-@gray-light: lighten(@gray-base, 70%);
-@gray-lighter: lighten(@gray-base, 95%);
+@gray-light: lighten(@gray-base, 80%);
+@gray-lighter: lighten(@gray-base, 90%);
@brand-primary: #00A699;
@brand-success: #4AC15F;
diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less
index d25dc6b73ccf..de9bb8a6c1b1 100644
--- a/superset/assets/stylesheets/superset.less
+++ b/superset/assets/stylesheets/superset.less
@@ -348,6 +348,9 @@ table.table-no-hover tr:hover {
.m-t-5 {
margin-top: 5px;
}
+.m-t-8 {
+ margin-top: 8px;
+}
.m-t-10 {
margin-top: 10px;
}
diff --git a/superset/cli.py b/superset/cli.py
index 6691b0148f8f..0e58dae969c3 100755
--- a/superset/cli.py
+++ b/superset/cli.py
@@ -29,6 +29,8 @@
from superset import (
app, appbuilder, data, db, security_manager,
)
+from superset.models import core as models
+from superset.tasks.thumbnails import cache_chart_thumbnail, cache_dashboard_thumbnail
from superset.utils import (
core as utils, dashboard_import_export, dict_import_export)
@@ -347,6 +349,29 @@ def flower(port, address):
Popen(cmd, shell=True).wait()
+'''
+@app.cli.command()
+def test_email():
+ """Test that email is configured"""
+ from superset.utils.email import send_email_smtp
+ send_email_smtp(
+ to='max@preset.io',
+ subject='it works!',
+ html_content='SUPER ',
+ config=app.config,
+ )
+ from superset.tasks.schedules import deliver_dashboard, deliver_slice
+ from superset.models.schedules import EmailDeliveryType, SliceEmailReportFormat
+ #dashboard = db.session.query(models.Dashboard).filter_by(id=2).one()
+ #deliver_dashboard(dashboard, [('max@preset.io', None)], EmailDeliveryType.inline)
+
+ slc = db.session.query(models.Slice).filter_by(id=76).one()
+ deliver_slice(
+ slc, [('max@preset.io', None)],
+ SliceEmailReportFormat.visualization, EmailDeliveryType.inline)
+'''
+
+
@app.cli.command()
def load_test_users():
"""
@@ -358,6 +383,57 @@ def load_test_users():
load_test_users_run()
+@app.cli.command()
+@click.option(
+ '--asynchronous', '-a', is_flag=True, default=False,
+ help='Trigger commands to run remotely on a worker')
+@click.option(
+ '--dashboards_only', '-d', is_flag=True, default=False,
+ help='Only process dashboards')
+@click.option(
+ '--charts_only', '-c', is_flag=True, default=False,
+ help='Only process charts')
+@click.option(
+ '--force', '-f', is_flag=True, default=False,
+ help='Force refresh, even if previously cached')
+@click.option('--id', '-i', multiple=True)
+def compute_thumbnails(asynchronous, dashboards_only, charts_only, force, id):
+ """Compute thumbnails"""
+ if not charts_only:
+ query = db.session.query(models.Dashboard)
+ if id:
+ query = query.filter(models.Dashboard.id.in_(id))
+ dashboards = query.all()
+ count = len(dashboards)
+ for i, dash in enumerate(dashboards):
+ if asynchronous:
+ func = cache_dashboard_thumbnail.delay
+ action = 'Triggering'
+ else:
+ func = cache_dashboard_thumbnail
+ action = 'Processing'
+ msg = f'{action} dashboard "{dash.dashboard_title}" ({i+1}/{count})'
+ click.secho(msg, fg='green')
+ func(dash.id, force=force)
+
+ if not dashboards_only:
+ query = db.session.query(models.Slice)
+ if id:
+ query = query.filter(models.Slice.id.in_(id))
+ slices = query.all()
+ count = len(slices)
+ for i, slc in enumerate(slices):
+ if asynchronous:
+ func = cache_chart_thumbnail.delay
+ action = 'Triggering'
+ else:
+ func = cache_chart_thumbnail
+ action = 'Processing'
+ msg = f'{action} chart "{slc.slice_name}" ({i+1}/{count})'
+ click.secho(msg, fg='green')
+ func(slc.id, force=force)
+
+
def load_test_users_run():
"""
Loads admin, alpha, and gamma user for testing purposes
diff --git a/superset/config.py b/superset/config.py
index 7d6ca33e5f9a..0fc768237d43 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -238,6 +238,7 @@
CACHE_DEFAULT_TIMEOUT = 60 * 60 * 24
CACHE_CONFIG = {'CACHE_TYPE': 'null'}
TABLE_NAMES_CACHE_CONFIG = {'CACHE_TYPE': 'null'}
+THUMBNAIL_CACHE_CONFIG = {'CACHE_TYPE': 'null'}
# CORS Options
ENABLE_CORS = False
@@ -370,6 +371,8 @@
# you'll want to use a proper broker as specified here:
# http://docs.celeryproject.org/en/latest/getting-started/brokers/index.html
+CELERYD_LOG_LEVEL = 'DEBUG'
+
class CeleryConfig(object):
BROKER_URL = 'sqla+sqlite:///celerydb.sqlite'
@@ -584,7 +587,9 @@ class CeleryConfig(object):
# Window size - this will impact the rendering of the data
WEBDRIVER_WINDOW = {
'dashboard': (1600, 2000),
- 'slice': (3000, 1200),
+ 'slice': (800, 600),
+ 'thumbnail_chart': (800, 600),
+ 'thumbnail_dashboard': (800, 600),
}
# Any config options to be passed as-is to the webdriver
diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py
index 57db0ad4684d..cccd87cf8a05 100644
--- a/superset/connectors/base/models.py
+++ b/superset/connectors/base/models.py
@@ -162,6 +162,16 @@ def short_data(self):
def select_star(self):
pass
+ @property
+ def data_summary(self):
+ return {
+ 'datasource_name': self.datasource_name,
+ 'type': self.type,
+ 'schema': self.schema,
+ 'id': self.id,
+ 'explore_url': self.explore_url,
+ }
+
@property
def data(self):
"""Data representation of the datasource sent to the frontend"""
diff --git a/superset/models/core.py b/superset/models/core.py
index b379af7caba2..b527f97248de 100644
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -51,6 +51,7 @@
from superset.models.helpers import AuditMixinNullable, ImportMixin
from superset.models.tags import ChartUpdater, DashboardUpdater, FavStarUpdater
from superset.models.user_attributes import UserAttribute
+from superset.tasks.thumbnails import cache_chart_thumbnail, cache_dashboard_thumbnail
from superset.utils import (
cache as cache_util,
core as utils,
@@ -175,6 +176,28 @@ def cls_model(self):
def datasource(self):
return self.get_datasource
+ @property
+ def datasource_data_summary(self):
+ return self.datasource.data_summary
+
+ @property
+ def thumbnail_url(self):
+ # SHA here is to force bypassing the browser cache when chart has changed
+ sha = utils.md5_hex(self.params, 6)
+ return f'/thumb/chart/{self.id}/{sha}/'
+
+ @property
+ def thumbnail_img(self):
+ return Markup(f' ')
+
+ @property
+ def thumbnail_link(self):
+ return Markup(f"""
+
+ {self.thumbnail_img}
+
+ """)
+
def clone(self):
return Slice(
slice_name=self.slice_name,
@@ -247,6 +270,7 @@ def data(self):
'modified': self.modified(),
'changed_on_humanized': self.changed_on_humanized,
'changed_on': self.changed_on.isoformat(),
+ 'thumbnail_url': self.thumbnail_url,
}
@property
@@ -375,8 +399,13 @@ def url(self):
)
-sqla.event.listen(Slice, 'before_insert', set_related_perm)
-sqla.event.listen(Slice, 'before_update', set_related_perm)
+def event_after_slice_changed(mapper, connection, target):
+ set_related_perm(mapper, connection, target)
+ cache_chart_thumbnail.delay(target.id, force=True)
+
+
+sqla.event.listen(Slice, 'before_insert', event_after_slice_changed)
+sqla.event.listen(Slice, 'before_update', event_after_slice_changed)
dashboard_slices = Table(
@@ -469,6 +498,7 @@ def data(self):
'dashboard_title': self.dashboard_title,
'slug': self.slug,
'slices': [slc.data for slc in self.slices],
+ 'thumbnail_url': self.thumbnail_url,
'position_json': positions,
}
@@ -657,6 +687,32 @@ def export_dashboards(cls, dashboard_ids):
'datasources': eager_datasources,
}, cls=utils.DashboardEncoder, indent=4)
+ @property
+ def thumbnail_url(self):
+ # SHA here is to force bypassing the browser cache when chart has changed
+ sha = utils.md5_hex(self.position_json, 6)
+ return f'/thumb/dashboard/{self.id}/{sha}/'
+
+ @property
+ def thumbnail_img(self):
+ return Markup(f' ')
+
+ @property
+ def thumbnail_link(self):
+ return Markup(f"""
+
+ {self.thumbnail_img}
+
+ """)
+
+
+def event_after_dashboard_changed(mapper, connection, target):
+ cache_dashboard_thumbnail.delay(target.id, force=True)
+
+
+sqla.event.listen(Dashboard, 'before_insert', event_after_dashboard_changed)
+sqla.event.listen(Dashboard, 'before_update', event_after_dashboard_changed)
+
class Database(Model, AuditMixinNullable, ImportMixin):
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 78b438d9f8f1..fd8d2f6dabc4 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -272,8 +272,13 @@ def _user_link(self, user):
return Markup('{} '.format(url, escape(user) or ''))
def changed_by_name(self):
- if self.created_by:
- return escape('{}'.format(self.created_by))
+ if self.changed_by:
+ return escape('{}'.format(self.changed_by))
+ return ''
+
+ def created_by_name(self):
+ if self.changed_by:
+ return escape('{}'.format(self.changed_by))
return ''
@renders('created_by')
diff --git a/superset/tasks/cache.py b/superset/tasks/cache.py
index 831bb6642914..acd3caddbd72 100644
--- a/superset/tasks/cache.py
+++ b/superset/tasks/cache.py
@@ -27,7 +27,6 @@
from sqlalchemy import and_, func
from superset import app, db
-from superset.models.core import Dashboard, Log, Slice
from superset.models.tags import Tag, TaggedObject
from superset.tasks.celery_app import app as celery_app
from superset.utils.core import parse_human_datetime
@@ -135,6 +134,7 @@ class DummyStrategy(Strategy):
def get_urls(self):
session = db.create_scoped_session()
+ from superset.models.core import Slice
charts = session.query(Slice).all()
return [get_url({'form_data': get_form_data(chart.id)}) for chart in charts]
@@ -169,6 +169,7 @@ def get_urls(self):
urls = []
session = db.create_scoped_session()
+ from superset.models.core import Dashboard, Log
records = (
session
.query(Log.dashboard_id, func.count(Log.dashboard_id))
@@ -240,6 +241,7 @@ def get_urls(self):
))
.all()
)
+ from superset.models.core import Dashboard
dash_ids = [tagged_object.object_id for tagged_object in tagged_objects]
tagged_dashboards = (
session
@@ -262,6 +264,7 @@ def get_urls(self):
.all()
)
chart_ids = [tagged_object.object_id for tagged_object in tagged_objects]
+ from superset.models.core import Slice
tagged_charts = (
session
.query(Slice)
diff --git a/superset/tasks/schedules.py b/superset/tasks/schedules.py
index b4ca9f41bdc0..b92d6083153b 100644
--- a/superset/tasks/schedules.py
+++ b/superset/tasks/schedules.py
@@ -22,23 +22,15 @@
from datetime import datetime, timedelta
from email.utils import make_msgid, parseaddr
import logging
-import time
-
import croniter
from dateutil.tz import tzlocal
-from flask import render_template, Response, session, url_for
+from flask import render_template, url_for
from flask_babel import gettext as __
-from flask_login import login_user
import requests
-from retry.api import retry_call
-from selenium.common.exceptions import WebDriverException
-from selenium.webdriver import chrome, firefox
import simplejson as json
from six.moves import urllib
-from werkzeug.utils import parse_cookie
-# Superset framework imports
from superset import app, db, security_manager
from superset.models.schedules import (
EmailDeliveryType,
@@ -47,47 +39,48 @@
SliceEmailReportFormat,
)
from superset.tasks.celery_app import app as celery_app
-from superset.utils.core import (
- get_email_address_list,
- send_email_smtp,
+from superset.utils import core as utils
+from superset.utils.selenium import (
+ DashboardScreenshot,
+ get_auth_cookies,
+ SliceScreenshot,
)
# Globals
-config = app.config
logging.getLogger('tasks.email_reports').setLevel(logging.INFO)
-# Time in seconds, we will wait for the page to load and render
-PAGE_RENDER_WAIT = 30
-
-
EmailContent = namedtuple('EmailContent', ['body', 'data', 'images'])
def _get_recipients(schedule):
- bcc = config.get('EMAIL_REPORT_BCC_ADDRESS', None)
+ bcc = app.config.get('EMAIL_REPORT_BCC_ADDRESS', None)
if schedule.deliver_as_group:
to = schedule.recipients
yield (to, bcc)
else:
- for to in get_email_address_list(schedule.recipients):
+ for to in utils.get_email_address_list(schedule.recipients):
yield (to, bcc)
-def _deliver_email(schedule, subject, email):
- for (to, bcc) in _get_recipients(schedule):
- send_email_smtp(
- to, subject, email.body, config,
+def _deliver_email(recipients, subject, email):
+ config = app.config
+ dryrun = config.get('SCHEDULED_EMAIL_DEBUG_MODE')
+ for (to, bcc) in recipients:
+ utils.send_email_smtp(
+ to, subject, email.body,
+ config,
data=email.data,
images=email.images,
bcc=bcc,
mime_subtype='related',
- dryrun=config.get('SCHEDULED_EMAIL_DEBUG_MODE'),
+ dryrun=dryrun,
)
-def _generate_mail_content(schedule, screenshot, name, url):
- if schedule.delivery_type == EmailDeliveryType.attachment:
+def _generate_mail_content(delivery_type, screenshot, name, url):
+ config = app.config
+ if delivery_type == EmailDeliveryType.attachment:
images = None
data = {
'screenshot.png': screenshot,
@@ -97,7 +90,9 @@ def _generate_mail_content(schedule, screenshot, name, url):
name=name,
url=url,
)
- elif schedule.delivery_type == EmailDeliveryType.inline:
+ else:
+ # Implicit: delivery_type == EmailDeliveryType.inline:
+
# Get the domain from the 'From' address ..
# and make a message id without the < > in the ends
domain = parseaddr(config.get('SMTP_MAIL_FROM'))[1].split('@')[1]
@@ -118,135 +113,33 @@ def _generate_mail_content(schedule, screenshot, name, url):
return EmailContent(body, data, images)
-def _get_auth_cookies():
- # Login with the user specified to get the reports
- with app.test_request_context():
- user = security_manager.find_user(config.get('EMAIL_REPORTS_USER'))
- login_user(user)
-
- # A mock response object to get the cookie information from
- response = Response()
- app.session_interface.save_session(app, session, response)
-
- cookies = []
-
- # Set the cookies in the driver
- for name, value in response.headers:
- if name.lower() == 'set-cookie':
- cookie = parse_cookie(value)
- cookies.append(cookie['session'])
-
- return cookies
-
-
def _get_url_path(view, **kwargs):
with app.test_request_context():
return urllib.parse.urljoin(
- str(config.get('WEBDRIVER_BASEURL')),
+ str(app.config.get('WEBDRIVER_BASEURL')),
url_for(view, **kwargs),
)
-def create_webdriver():
- # Create a webdriver for use in fetching reports
- if config.get('EMAIL_REPORTS_WEBDRIVER') == 'firefox':
- driver_class = firefox.webdriver.WebDriver
- options = firefox.options.Options()
- elif config.get('EMAIL_REPORTS_WEBDRIVER') == 'chrome':
- driver_class = chrome.webdriver.WebDriver
- options = chrome.options.Options()
-
- options.add_argument('--headless')
-
- # Prepare args for the webdriver init
- kwargs = dict(
- options=options,
- )
- kwargs.update(config.get('WEBDRIVER_CONFIGURATION'))
-
- # Initialize the driver
- driver = driver_class(**kwargs)
-
- # Some webdrivers need an initial hit to the welcome URL
- # before we set the cookie
- welcome_url = _get_url_path('Superset.welcome')
-
- # Hit the welcome URL and check if we were asked to login
- driver.get(welcome_url)
- elements = driver.find_elements_by_id('loginbox')
-
- # This indicates that we were not prompted for a login box.
- if not elements:
- return driver
-
- # Set the cookies in the driver
- for cookie in _get_auth_cookies():
- info = dict(name='session', value=cookie)
- driver.add_cookie(info)
-
- return driver
-
-
-def destroy_webdriver(driver):
- """
- Destroy a driver
- """
-
- # This is some very flaky code in selenium. Hence the retries
- # and catch-all exceptions
- try:
- retry_call(driver.close, tries=2)
- except Exception:
- pass
- try:
- driver.quit()
- except Exception:
- pass
-
-
-def deliver_dashboard(schedule):
+def deliver_dashboard(dashboard, recipients, delivery_type):
"""
Given a schedule, delivery the dashboard as an email report
"""
- dashboard = schedule.dashboard
-
- dashboard_url = _get_url_path(
- 'Superset.dashboard',
- dashboard_id=dashboard.id,
- )
+ config = app.config
- # Create a driver, fetch the page, wait for the page to render
- driver = create_webdriver()
window = config.get('WEBDRIVER_WINDOW')['dashboard']
- driver.set_window_size(*window)
- driver.get(dashboard_url)
- time.sleep(PAGE_RENDER_WAIT)
-
- # Set up a function to retry once for the element.
- # This is buggy in certain selenium versions with firefox driver
- get_element = getattr(driver, 'find_element_by_class_name')
- element = retry_call(
- get_element,
- fargs=['grid-container'],
- tries=2,
- delay=PAGE_RENDER_WAIT,
- )
-
- try:
- screenshot = element.screenshot_as_png
- except WebDriverException:
- # Some webdrivers do not support screenshots for elements.
- # In such cases, take a screenshot of the entire page.
- screenshot = driver.screenshot() # pylint: disable=no-member
- finally:
- destroy_webdriver(driver)
+ user = security_manager.find_user(config.get('EMAIL_REPORTS_USER'))
+ with app.app_context():
+ screenshot = DashboardScreenshot(id=dashboard.id)
+ img = screenshot.get_thumb(
+ user=user, window_size=window, thumb_size=window)
# Generate the email body and attachments
email = _generate_mail_content(
- schedule,
- screenshot,
+ delivery_type,
+ img,
dashboard.dashboard_title,
- dashboard_url,
+ screenshot.url,
)
subject = __(
@@ -255,11 +148,11 @@ def deliver_dashboard(schedule):
title=dashboard.dashboard_title,
)
- _deliver_email(schedule, subject, email)
+ _deliver_email(recipients, subject, email)
-def _get_slice_data(schedule):
- slc = schedule.slice
+def _get_slice_data(slc, delivery_type):
+ config = app.config
slice_url = _get_url_path(
'Superset.explore_json',
@@ -274,8 +167,10 @@ def _get_slice_data(schedule):
)
cookies = {}
- for cookie in _get_auth_cookies():
- cookies['session'] = cookie
+ user = security_manager.find_user(config.get('EMAIL_REPORTS_USER'))
+ with app.app_context():
+ for cookie in get_auth_cookies(user):
+ cookies['session'] = cookie
response = requests.get(slice_url, cookies=cookies)
response.raise_for_status()
@@ -283,7 +178,7 @@ def _get_slice_data(schedule):
# TODO: Move to the csv module
rows = [r.split(b',') for r in response.content.splitlines()]
- if schedule.delivery_type == EmailDeliveryType.inline:
+ if delivery_type == EmailDeliveryType.inline:
data = None
# Parse the csv file and generate HTML
@@ -297,7 +192,7 @@ def _get_slice_data(schedule):
link=url,
)
- elif schedule.delivery_type == EmailDeliveryType.attachment:
+ elif delivery_type == EmailDeliveryType.attachment:
data = {
__('%(name)s.csv', name=slc.slice_name): response.content,
}
@@ -310,67 +205,48 @@ def _get_slice_data(schedule):
return EmailContent(body, data, None)
-def _get_slice_visualization(schedule):
- slc = schedule.slice
-
- # Create a driver, fetch the page, wait for the page to render
- driver = create_webdriver()
- window = config.get('WEBDRIVER_WINDOW')['slice']
- driver.set_window_size(*window)
-
- slice_url = _get_url_path(
+def _get_slice_visualization(slc, delivery_type):
+ config = app.config
+ url = _get_url_path(
'Superset.slice',
slice_id=slc.id,
)
+ window = config.get('WEBDRIVER_WINDOW')['slice']
+ user = security_manager.find_user(config.get('EMAIL_REPORTS_USER'))
- driver.get(slice_url)
- time.sleep(PAGE_RENDER_WAIT)
-
- # Set up a function to retry once for the element.
- # This is buggy in certain selenium versions with firefox driver
- element = retry_call(
- driver.find_element_by_class_name,
- fargs=['chart-container'],
- tries=2,
- delay=PAGE_RENDER_WAIT,
- )
-
- try:
- screenshot = element.screenshot_as_png
- except WebDriverException:
- # Some webdrivers do not support screenshots for elements.
- # In such cases, take a screenshot of the entire page.
- screenshot = driver.screenshot() # pylint: disable=no-member
- finally:
- destroy_webdriver(driver)
+ with app.app_context():
+ screenshot = SliceScreenshot(id=slc.id)
+ img = screenshot.get_thumb(
+ user=user, window_size=window, thumb_size=window)
# Generate the email body and attachments
return _generate_mail_content(
- schedule,
- screenshot,
+ delivery_type,
+ img,
slc.slice_name,
- slice_url,
+ url,
)
-def deliver_slice(schedule):
+def deliver_slice(slc, recipients, email_format, delivery_type):
"""
Given a schedule, delivery the slice as an email report
"""
- if schedule.email_format == SliceEmailReportFormat.data:
- email = _get_slice_data(schedule)
- elif schedule.email_format == SliceEmailReportFormat.visualization:
- email = _get_slice_visualization(schedule)
+ config = app.config
+ if email_format == SliceEmailReportFormat.data:
+ email = _get_slice_data(slc, delivery_type)
+ elif email_format == SliceEmailReportFormat.visualization:
+ email = _get_slice_visualization(slc, delivery_type)
else:
raise RuntimeError('Unknown email report format')
subject = __(
'%(prefix)s %(title)s',
prefix=config.get('EMAIL_REPORTS_SUBJECT_PREFIX'),
- title=schedule.slice.slice_name,
+ title=slc.slice_name,
)
- _deliver_email(schedule, subject, email)
+ _deliver_email(recipients, subject, email)
@celery_app.task(name='email_reports.send', bind=True, soft_time_limit=300)
@@ -389,9 +265,15 @@ def schedule_email_report(task, report_type, schedule_id, recipients=None):
schedule.recipients = recipients
if report_type == ScheduleType.dashboard.value:
- deliver_dashboard(schedule)
+ deliver_dashboard(
+ schedule.dashboard, _get_recipients(schedule), schedule.delivery_type)
elif report_type == ScheduleType.slice.value:
- deliver_slice(schedule)
+ deliver_slice(
+ schedule.slice,
+ _get_recipients(schedule),
+ schedule.email_format,
+ schedule.delivery_type,
+ )
else:
raise RuntimeError('Unknown report type')
@@ -443,6 +325,7 @@ def schedule_window(report_type, start_at, stop_at, resolution):
@celery_app.task(name='email_reports.schedule_hourly')
def schedule_hourly():
""" Celery beat job meant to be invoked hourly """
+ config = app.config
if not config.get('ENABLE_SCHEDULED_EMAIL_REPORTS'):
logging.info('Scheduled email reports not enabled in config')
diff --git a/superset/tasks/thumbnails.py b/superset/tasks/thumbnails.py
new file mode 100644
index 000000000000..a4cbd46a5d6a
--- /dev/null
+++ b/superset/tasks/thumbnails.py
@@ -0,0 +1,43 @@
+# 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.
+# pylint: disable=C,R,W
+
+"""Utility functions used across Superset"""
+
+import logging
+
+from superset import app, security_manager, thumbnail_cache
+from superset.tasks.celery_app import app as celery_app
+from superset.utils.selenium import DashboardScreenshot, SliceScreenshot
+
+
+@celery_app.task(name='cache_chart_thumbnail', soft_time_limit=300)
+def cache_chart_thumbnail(chart_id, force=False):
+ with app.app_context():
+ logging.info(f'Caching chart {chart_id}')
+ screenshot = SliceScreenshot(id=chart_id)
+ user = security_manager.find_user('Admin')
+ screenshot.compute_and_cache(user=user, cache=thumbnail_cache, force=force)
+
+
+@celery_app.task(name='cache_dashboard_thumbnail', soft_time_limit=300)
+def cache_dashboard_thumbnail(dashboard_id, force=False):
+ with app.app_context():
+ logging.info(f'Caching dashboard {dashboard_id}')
+ screenshot = DashboardScreenshot(id=dashboard_id)
+ user = security_manager.find_user('Admin')
+ screenshot.compute_and_cache(user=user, cache=thumbnail_cache, force=force)
diff --git a/superset/utils/selenium.py b/superset/utils/selenium.py
new file mode 100644
index 000000000000..0e70ed241590
--- /dev/null
+++ b/superset/utils/selenium.py
@@ -0,0 +1,297 @@
+# 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.
+# pylint: disable=C,R,W
+from io import BytesIO
+import logging
+import time
+import urllib
+
+from flask import current_app, request, Response, session, url_for
+from flask_login import login_user
+from PIL import Image
+from retry.api import retry_call
+from selenium.common.exceptions import TimeoutException, WebDriverException
+from selenium.webdriver import chrome, firefox
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import WebDriverWait
+from werkzeug.utils import parse_cookie
+
+# Time in seconds, we will wait for the page to load and render
+SELENIUM_CHECK_INTERVAL = 2
+SELENIUM_RETRIES = 5
+SELENIUM_HEADSTART = 3
+
+
+def headless_url(path):
+ return urllib.parse.urljoin(
+ current_app.config.get('WEBDRIVER_BASEURL'),
+ path,
+ )
+
+
+def get_url_path(view, **kwargs):
+ with current_app.test_request_context():
+ return headless_url(url_for(view, **kwargs))
+
+
+class BaseScreenshot:
+ thumbnail_type = None
+ orm_class = None
+ element = None
+ window_size = (800, 600)
+ thumb_size = (400, 300)
+
+ def __init__(self, id):
+ self.id = id
+ self.screenshot = None
+
+ @property
+ def cache_key(self):
+ return f'thumb__{self.thumbnail_type}__{self.id}'
+
+ @property
+ def url(self):
+ raise NotImplementedError()
+
+ def fetch_screenshot(self, user, window_size=None):
+ window_size = window_size or self.window_size
+ self.screenshot = get_png_from_url(
+ self.url, window_size, self.element, user=user)
+ return self.screenshot
+
+ def get_thumb_as_bytes(self, *args, **kwargs):
+ payload = self.get_thumb(*args, **kwargs)
+ return BytesIO(payload)
+
+ def get_from_cache(self, cache):
+ payload = cache.get(self.cache_key)
+ if payload:
+ return BytesIO(payload)
+
+ def compute_and_cache(
+ self, user, cache, window_size=None, thumb_size=None,
+ force=True):
+ cache_key = self.cache_key
+ if not force and cache.get(cache_key):
+ logging.info('Thumb already cached, skipping...')
+ return
+ window_size = window_size or self.window_size
+ thumb_size = thumb_size or self.thumb_size
+ logging.info(f'Processing url for thumbnail: {cache_key}')
+
+ payload = None
+
+ # Assuming all sorts of things can go wrong with Selenium
+ try:
+ payload = self.fetch_screenshot(window_size=window_size, user=user)
+ except Exception as e:
+ logging.error('Failed at generating thumbnail')
+ logging.exception(e)
+
+ if payload and window_size != thumb_size:
+ try:
+ payload = self.resize_image(payload, size=thumb_size)
+ except Exception as e:
+ logging.error('Failed at resizing thumbnail')
+ logging.exception(e)
+ payload = None
+
+ if payload and cache:
+ logging.info(f'Caching thumbnail: {cache_key}')
+ cache.set(cache_key, payload)
+
+ return payload
+
+ def get_thumb(
+ self, user,
+ window_size=None, thumb_size=None,
+ cache=None,
+ ):
+ payload = None
+ cache_key = self.cache_key
+ window_size = window_size or self.window_size
+ thumb_size = thumb_size or self.thumb_size
+ if cache:
+ payload = cache.get(cache_key)
+ if not payload:
+ payload = self.compute_and_cache(user, cache, window_size, thumb_size)
+ else:
+ logging.info(f'Loaded thumbnail from cache: {cache_key}')
+ return payload
+
+ @classmethod
+ def resize_image(cls, img_bytes, output='png', size=None, crop=True):
+ size = size or cls.thumb_size
+ img = Image.open(BytesIO(img_bytes))
+ logging.debug(f'Selenium image size: {img.size}')
+ if crop and img.size[1] != cls.window_size[1]:
+ desired_ratio = float(cls.window_size[1]) / cls.window_size[0]
+ desired_width = int(img.size[0] * desired_ratio)
+ logging.debug(f'Cropping to: {img.size[0]}*{desired_width}')
+ img = img.crop((0, 0, img.size[0], desired_width))
+ logging.debug(f'Resizing to {size}')
+ img = img.resize(size, Image.ANTIALIAS)
+ new_img = BytesIO()
+ if output != 'png':
+ img = img.convert('RGB')
+ img.save(new_img, output)
+ new_img.seek(0)
+ return new_img.read()
+
+
+class SliceScreenshot(BaseScreenshot):
+ thumbnail_type = 'slice'
+ element = 'chart-container'
+ window_size = (600, int(600 * 0.75))
+ thumb_size = (300, int(300 * 0.75))
+
+ @property
+ def url(self):
+ return get_url_path(
+ 'Superset.slice',
+ slice_id=self.id,
+ standalone='true',
+ )
+
+
+class DashboardScreenshot(BaseScreenshot):
+ thumbnail_type = 'dashboard'
+ element = 'grid-container'
+ window_size = (1600, int(1600 * 0.75))
+ thumb_size = (400, int(400 * 0.75))
+
+ @property
+ def url(self):
+ return get_url_path(
+ 'Superset.dashboard',
+ dashboard_id=self.id,
+ )
+
+
+def _destroy_webdriver(driver):
+ """Destroy a driver"""
+ # This is some very flaky code in selenium. Hence the retries
+ # and catch-all exceptions
+ try:
+ retry_call(driver.close, tries=2)
+ except Exception:
+ pass
+ try:
+ driver.quit()
+ except Exception:
+ pass
+
+
+def get_auth_cookies(user):
+ # Login with the user specified to get the reports
+ with current_app.test_request_context():
+ login_user(user)
+
+ # A mock response object to get the cookie information from
+ response = Response()
+ current_app.session_interface.save_session(current_app, session, response)
+
+ cookies = []
+
+ # Set the cookies in the driver
+ for name, value in response.headers:
+ if name.lower() == 'set-cookie':
+ cookie = parse_cookie(value)
+ cookies.append(cookie['session'])
+ return cookies
+
+
+def create_webdriver(user=None, webdriver='chrome', window=None):
+ """Creates a selenium webdriver
+
+ If no user is specified, we use the current request's context"""
+ # Create a webdriver for use in fetching reports
+ if webdriver == 'firefox':
+ driver_class = firefox.webdriver.WebDriver
+ options = firefox.options.Options()
+ else:
+ # webdriver == 'chrome':
+ driver_class = chrome.webdriver.WebDriver
+ options = chrome.options.Options()
+ arg = f'--window-size={window[0]},{window[1]}'
+ options.add_argument(arg)
+
+ options.add_argument('--headless')
+
+ # Prepare args for the webdriver init
+ kwargs = dict(
+ options=options,
+ )
+ kwargs.update(current_app.config.get('WEBDRIVER_CONFIGURATION'))
+
+ logging.info('Init selenium driver')
+ driver = driver_class(**kwargs)
+
+ # Setting cookies requires doing a request first
+ driver.get(headless_url('/login/'))
+
+ if user:
+ # Set the cookies in the driver
+ for cookie in get_auth_cookies(user):
+ info = dict(name='session', value=cookie)
+ driver.add_cookie(info)
+ elif request.cookies:
+ cookies = request.cookies
+ for k, v in cookies.items():
+ cookie = dict(name=k, value=v)
+ driver.add_cookie(cookie)
+ return driver
+
+
+def get_png_from_url(
+ url,
+ window,
+ element,
+ user,
+ webdriver='chrome',
+ retries=SELENIUM_RETRIES,
+):
+ driver = create_webdriver(user, webdriver, window)
+ driver.set_window_size(*window)
+ driver.get(url)
+ img = None
+ logging.debug(f'Sleeping for {SELENIUM_HEADSTART} seconds')
+ time.sleep(SELENIUM_HEADSTART)
+ try:
+ logging.debug(f'Wait for the presence of {element}')
+ element = WebDriverWait(driver, 10).until(
+ EC.presence_of_element_located((By.CLASS_NAME, element)),
+ )
+ logging.debug(f'Wait for .loading to be done')
+ WebDriverWait(driver, 60).until_not(
+ EC.presence_of_all_elements_located(
+ (By.CLASS_NAME, 'loading'),
+ ),
+ )
+ logging.info('Taking a PNG screenshot')
+ img = element.screenshot_as_png
+ except TimeoutException:
+ logging.error('Selenium timed out')
+ except WebDriverException as e:
+ logging.exception(e)
+ # Some webdrivers do not support screenshots for elements.
+ # In such cases, take a screenshot of the entire page.
+ img = driver.screenshot() # pylint: disable=no-member
+ finally:
+ _destroy_webdriver(driver)
+ return img
diff --git a/superset/views/__init__.py b/superset/views/__init__.py
index 380ea6ec93f1..db726188342f 100644
--- a/superset/views/__init__.py
+++ b/superset/views/__init__.py
@@ -19,6 +19,7 @@
from . import core # noqa
from . import sql_lab # noqa
from . import dashboard # noqa
+from . import thumbnails # noqa
from . import annotations # noqa
from . import datasource # noqa
from . import schedules # noqa
diff --git a/superset/views/core.py b/superset/views/core.py
index d8a3692c2440..8015c748b589 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -58,7 +58,7 @@
from superset.sql_validators import get_validator_by_name
from superset.utils import core as utils
from superset.utils import dashboard_import_export
-from superset.utils.dates import now_as_float
+from superset.utils.dates import EPOCH, now_as_float
from superset.utils.decorators import etag_cache
from .base import (
api, BaseSupersetView,
@@ -499,7 +499,9 @@ class SliceModelView(SupersetModelView, DeleteMixin): # noqa
'slice_name', 'description', 'viz_type', 'datasource_name', 'owners',
)
list_columns = [
- 'slice_link', 'viz_type', 'datasource_link', 'creator', 'modified']
+ 'slice_link', 'viz_type', 'datasource_link',
+ 'creator', 'modified',
+ ]
order_columns = ['viz_type', 'datasource_link', 'modified']
edit_columns = [
'slice_name', 'description', 'viz_type', 'owners', 'dashboards',
@@ -576,7 +578,9 @@ class SliceAsync(SliceModelView): # noqa
route_base = '/sliceasync'
list_columns = [
'id', 'slice_link', 'viz_type', 'slice_name',
- 'creator', 'modified', 'icons', 'changed_on_humanized',
+ 'creator', 'modified', 'icons', 'thumbnail_url',
+ 'slice_url', 'created_by_name', 'changed_on',
+ 'datasource_data_summary', 'changed_on_humanized',
]
label_columns = {
'icons': ' ',
@@ -709,7 +713,8 @@ class DashboardModelViewAsync(DashboardModelView): # noqa
route_base = '/dashboardasync'
list_columns = [
'id', 'dashboard_link', 'creator', 'modified', 'dashboard_title',
- 'changed_on', 'url', 'changed_by_name',
+ 'changed_on', 'url', 'changed_by_name', 'created_by_name',
+ 'thumbnail_url', 'changed_on_humanized',
]
label_columns = {
'dashboard_link': _('Dashboard'),
@@ -2797,7 +2802,7 @@ def queries(self, last_updated_ms):
last_updated_ms_int = int(float(last_updated_ms)) if last_updated_ms else 0
# UTC date time, same that is stored in the DB.
- last_updated_dt = utils.EPOCH + timedelta(seconds=last_updated_ms_int / 1000)
+ last_updated_dt = EPOCH + timedelta(seconds=last_updated_ms_int / 1000)
sql_queries = (
db.session.query(Query)
@@ -2923,7 +2928,7 @@ def profile(self, username):
'superset/basic.html',
title=_("%(user)s's profile", user=username),
entry='profile',
- bootstrap_data=json.dumps(payload, default=utils.json_iso_dttm_ser),
+ bootstrap_data=json.dumps(payload, default=utils.utils.json_iso_dttm_ser),
)
@has_access
diff --git a/superset/views/schedules.py b/superset/views/schedules.py
index 6fdae7726521..a7afda6e56bc 100644
--- a/superset/views/schedules.py
+++ b/superset/views/schedules.py
@@ -37,10 +37,7 @@
SliceEmailSchedule,
)
from superset.tasks.schedules import schedule_email_report
-from superset.utils.core import (
- get_email_address_list,
- json_iso_dttm_ser,
-)
+from superset.utils.core import get_email_address_list, json_iso_dttm_ser
from superset.views.core import json_success
from .base import DeleteMixin, SupersetModelView
diff --git a/superset/views/thumbnails.py b/superset/views/thumbnails.py
new file mode 100644
index 000000000000..281ea17c9eb7
--- /dev/null
+++ b/superset/views/thumbnails.py
@@ -0,0 +1,38 @@
+# pylint: disable=C,R,W
+from flask import redirect, send_file
+from flask_appbuilder import expose
+from flask_appbuilder.security.decorators import has_access
+
+from superset import app, appbuilder, thumbnail_cache
+from superset.utils.selenium import DashboardScreenshot, SliceScreenshot
+from .base import BaseSupersetView
+
+config = app.config
+NO_IMAGE_SRC = '/static/assets/images/no-image.png'
+
+
+class Thumb(BaseSupersetView):
+ """Base views for thumbnails"""
+ @expose('/chart///')
+ @has_access
+ def chart(self, slice_id, sha=None):
+ """Returns an thumbnail for a given chart, uses cache if possible"""
+ # TODO security
+ screenshot = SliceScreenshot(id=slice_id)
+ img = screenshot.get_from_cache(thumbnail_cache)
+ if not img:
+ return redirect(NO_IMAGE_SRC)
+ return send_file(img, mimetype='image/png')
+
+ @expose('/dashboard///')
+ @has_access
+ def dashboard(self, dashboard_id, sha=None):
+ """Returns an thumbnail for a given dash, uses cache if possible"""
+ screenshot = DashboardScreenshot(id=dashboard_id)
+ img = screenshot.get_from_cache(thumbnail_cache)
+ if not img:
+ return redirect(NO_IMAGE_SRC)
+ return send_file(img, mimetype='image/png')
+
+
+appbuilder.add_view_no_menu(Thumb)
diff --git a/tests/access_tests.py b/tests/access_tests.py
index 0bc1743cfe1f..cc0848280587 100644
--- a/tests/access_tests.py
+++ b/tests/access_tests.py
@@ -330,7 +330,7 @@ def test_clean_requests_after_schema_grant(self):
session.commit()
- @mock.patch('superset.utils.core.send_MIME_email')
+ @mock.patch('superset.utils.email.send_MIME_email')
def test_approve(self, mock_send_mime):
if app.config.get('ENABLE_ACCESS_REQUEST'):
session = db.session
diff --git a/tests/email_tests.py b/tests/email_tests.py
index 7e1830ccac5b..25da9cce4529 100644
--- a/tests/email_tests.py
+++ b/tests/email_tests.py
@@ -25,7 +25,7 @@
from unittest import mock
from superset import app
-from superset.utils import core as utils
+from superset.utils.email import send_email_smtp, send_MIME_email
from .utils import read_fixture
send_email_test = mock.Mock()
@@ -35,12 +35,12 @@ class EmailSmtpTest(unittest.TestCase):
def setUp(self):
app.config['smtp_ssl'] = False
- @mock.patch('superset.utils.core.send_MIME_email')
+ @mock.patch('superset.utils.email.send_MIME_email')
def test_send_smtp(self, mock_send_mime):
attachment = tempfile.NamedTemporaryFile()
attachment.write(b'attachment')
attachment.seek(0)
- utils.send_email_smtp(
+ send_email_smtp(
'to', 'subject', 'content', app.config, files=[attachment.name])
assert mock_send_mime.called
call_args = mock_send_mime.call_args[0]
@@ -54,9 +54,9 @@ def test_send_smtp(self, mock_send_mime):
mimeapp = MIMEApplication('attachment')
assert msg.get_payload()[-1].get_payload() == mimeapp.get_payload()
- @mock.patch('superset.utils.core.send_MIME_email')
+ @mock.patch('superset.utils.email.send_MIME_email')
def test_send_smtp_data(self, mock_send_mime):
- utils.send_email_smtp(
+ send_email_smtp(
'to', 'subject', 'content', app.config, data={'1.txt': b'data'})
assert mock_send_mime.called
call_args = mock_send_mime.call_args[0]
@@ -70,10 +70,10 @@ def test_send_smtp_data(self, mock_send_mime):
mimeapp = MIMEApplication('data')
assert msg.get_payload()[-1].get_payload() == mimeapp.get_payload()
- @mock.patch('superset.utils.core.send_MIME_email')
+ @mock.patch('superset.utils.email.send_MIME_email')
def test_send_smtp_inline_images(self, mock_send_mime):
image = read_fixture('sample.png')
- utils.send_email_smtp(
+ send_email_smtp(
'to', 'subject', 'content', app.config, images=dict(blah=image))
assert mock_send_mime.called
call_args = mock_send_mime.call_args[0]
@@ -87,12 +87,12 @@ def test_send_smtp_inline_images(self, mock_send_mime):
mimeapp = MIMEImage(image)
assert msg.get_payload()[-1].get_payload() == mimeapp.get_payload()
- @mock.patch('superset.utils.core.send_MIME_email')
+ @mock.patch('superset.utils.email.send_MIME_email')
def test_send_bcc_smtp(self, mock_send_mime):
attachment = tempfile.NamedTemporaryFile()
attachment.write(b'attachment')
attachment.seek(0)
- utils.send_email_smtp(
+ send_email_smtp(
'to', 'subject', 'content', app.config, files=[attachment.name],
cc='cc', bcc='bcc')
assert mock_send_mime.called
@@ -112,7 +112,7 @@ def test_send_mime(self, mock_smtp, mock_smtp_ssl):
mock_smtp.return_value = mock.Mock()
mock_smtp_ssl.return_value = mock.Mock()
msg = MIMEMultipart()
- utils.send_MIME_email('from', 'to', msg, app.config, dryrun=False)
+ send_MIME_email('from', 'to', msg, app.config, dryrun=False)
mock_smtp.assert_called_with(
app.config.get('SMTP_HOST'),
app.config.get('SMTP_PORT'),
@@ -132,7 +132,7 @@ def test_send_mime_ssl(self, mock_smtp, mock_smtp_ssl):
app.config['SMTP_SSL'] = True
mock_smtp.return_value = mock.Mock()
mock_smtp_ssl.return_value = mock.Mock()
- utils.send_MIME_email(
+ send_MIME_email(
'from', 'to', MIMEMultipart(), app.config, dryrun=False)
assert not mock_smtp.called
mock_smtp_ssl.assert_called_with(
@@ -147,7 +147,7 @@ def test_send_mime_noauth(self, mock_smtp, mock_smtp_ssl):
app.config['SMTP_PASSWORD'] = None
mock_smtp.return_value = mock.Mock()
mock_smtp_ssl.return_value = mock.Mock()
- utils.send_MIME_email(
+ send_MIME_email(
'from', 'to', MIMEMultipart(), app.config, dryrun=False)
assert not mock_smtp_ssl.called
mock_smtp.assert_called_with(
@@ -159,7 +159,7 @@ def test_send_mime_noauth(self, mock_smtp, mock_smtp_ssl):
@mock.patch('smtplib.SMTP_SSL')
@mock.patch('smtplib.SMTP')
def test_send_mime_dryrun(self, mock_smtp, mock_smtp_ssl):
- utils.send_MIME_email(
+ send_MIME_email(
'from', 'to', MIMEMultipart(), app.config, dryrun=True)
assert not mock_smtp.called
assert not mock_smtp_ssl.called
diff --git a/tests/schedules_test.py b/tests/schedules_test.py
index b0d311ab89e9..96748ece1782 100644
--- a/tests/schedules_test.py
+++ b/tests/schedules_test.py
@@ -21,7 +21,7 @@
from flask_babel import gettext as __
from selenium.common.exceptions import WebDriverException
-from superset import app, db
+from superset import app, db, security_manager
from superset.models.core import Dashboard, Slice
from superset.models.schedules import (
DashboardEmailSchedule,
@@ -30,11 +30,11 @@
SliceEmailSchedule,
)
from superset.tasks.schedules import (
- create_webdriver,
deliver_dashboard,
deliver_slice,
next_schedules,
)
+from superset.utils.selenium import create_webdriver
from .utils import read_fixture
@@ -149,19 +149,20 @@ def test_complex_schedule(self):
self.assertEqual(schedules[59], datetime.strptime('2018-03-30 17:40:00', fmt))
self.assertEqual(schedules[60], datetime.strptime('2018-05-04 17:10:00', fmt))
- @patch('superset.tasks.schedules.firefox.webdriver.WebDriver')
+ @patch('superset.utils.selenium.firefox.webdriver.WebDriver')
def test_create_driver(self, mock_driver_class):
mock_driver = Mock()
mock_driver_class.return_value = mock_driver
mock_driver.find_elements_by_id.side_effect = [True, False]
- create_webdriver()
- create_webdriver()
+ alpha_user = security_manager.find_user(username='alpha')
+ with app.app_context():
+ create_webdriver(alpha_user, webdriver='firefox')
mock_driver.add_cookie.assert_called_once()
- @patch('superset.tasks.schedules.firefox.webdriver.WebDriver')
+ @patch('superset.utils.selenium.firefox.webdriver.WebDriver')
@patch('superset.tasks.schedules.send_email_smtp')
- @patch('superset.tasks.schedules.time')
+ @patch('superset.utils.selenium.time')
def test_deliver_dashboard_inline(self, mtime, send_email_smtp, driver_class):
element = Mock()
driver = Mock()
@@ -182,9 +183,9 @@ def test_deliver_dashboard_inline(self, mtime, send_email_smtp, driver_class):
driver.screenshot.assert_not_called()
send_email_smtp.assert_called_once()
- @patch('superset.tasks.schedules.firefox.webdriver.WebDriver')
+ @patch('superset.utils.selenium.firefox.webdriver.WebDriver')
@patch('superset.tasks.schedules.send_email_smtp')
- @patch('superset.tasks.schedules.time')
+ @patch('superset.utils.selenium.time')
def test_deliver_dashboard_as_attachment(self, mtime, send_email_smtp, driver_class):
element = Mock()
driver = Mock()
@@ -213,9 +214,9 @@ def test_deliver_dashboard_as_attachment(self, mtime, send_email_smtp, driver_cl
element.screenshot_as_png,
)
- @patch('superset.tasks.schedules.firefox.webdriver.WebDriver')
+ @patch('superset.utils.selenium.firefox.webdriver.WebDriver')
@patch('superset.tasks.schedules.send_email_smtp')
- @patch('superset.tasks.schedules.time')
+ @patch('superset.utils.selenium.time')
def test_dashboard_chrome_like(self, mtime, send_email_smtp, driver_class):
# Test functionality for chrome driver which does not support
# element snapshots
@@ -236,6 +237,7 @@ def test_dashboard_chrome_like(self, mtime, send_email_smtp, driver_class):
id=self.dashboard_schedule).all()[0]
deliver_dashboard(schedule)
+
mtime.sleep.assert_called_once()
driver.screenshot.assert_called_once()
send_email_smtp.assert_called_once()
@@ -246,9 +248,9 @@ def test_dashboard_chrome_like(self, mtime, send_email_smtp, driver_class):
driver.screenshot.return_value,
)
- @patch('superset.tasks.schedules.firefox.webdriver.WebDriver')
+ @patch('superset.utils.selenium.firefox.webdriver.WebDriver')
@patch('superset.tasks.schedules.send_email_smtp')
- @patch('superset.tasks.schedules.time')
+ @patch('superset.utils.selenium.time')
def test_deliver_email_options(self, mtime, send_email_smtp, driver_class):
element = Mock()
driver = Mock()
@@ -277,9 +279,9 @@ def test_deliver_email_options(self, mtime, send_email_smtp, driver_class):
self.assertEquals(send_email_smtp.call_count, 2)
self.assertEquals(send_email_smtp.call_args[1]['bcc'], self.BCC)
- @patch('superset.tasks.schedules.firefox.webdriver.WebDriver')
+ @patch('superset.utils.selenium.firefox.webdriver.WebDriver')
@patch('superset.tasks.schedules.send_email_smtp')
- @patch('superset.tasks.schedules.time')
+ @patch('superset.utils.selenium.time')
def test_deliver_slice_inline_image(self, mtime, send_email_smtp, driver_class):
element = Mock()
driver = Mock()
@@ -308,9 +310,9 @@ def test_deliver_slice_inline_image(self, mtime, send_email_smtp, driver_class):
element.screenshot_as_png,
)
- @patch('superset.tasks.schedules.firefox.webdriver.WebDriver')
+ @patch('superset.utils.selenium.firefox.webdriver.WebDriver')
@patch('superset.tasks.schedules.send_email_smtp')
- @patch('superset.tasks.schedules.time')
+ @patch('superset.utils.selenium.time')
def test_deliver_slice_attachment(self, mtime, send_email_smtp, driver_class):
element = Mock()
driver = Mock()
diff --git a/tests/sqllab_tests.py b/tests/sqllab_tests.py
index dc86866a9960..5c328d4d6725 100644
--- a/tests/sqllab_tests.py
+++ b/tests/sqllab_tests.py
@@ -25,7 +25,8 @@
from superset.dataframe import SupersetDataFrame
from superset.db_engine_specs import BaseEngineSpec
from superset.models.sql_lab import Query
-from superset.utils.core import datetime_to_epoch, get_main_database
+from superset.utils.core import get_main_database
+from superset.utils.dates import datetime_to_epoch
from .base_tests import SupersetTestCase
diff --git a/tests/utils_tests.py b/tests/utils_tests.py
index a39631b83227..ed95e0244351 100644
--- a/tests/utils_tests.py
+++ b/tests/utils_tests.py
@@ -24,13 +24,9 @@
from superset.exceptions import SupersetException
from superset.utils.core import (
- base_json_conv,
convert_legacy_filters_into_adhoc,
datetime_f,
get_since_until,
- json_int_dttm_ser,
- json_iso_dttm_ser,
- JSONEncodedDict,
memoized,
merge_extra_filters,
merge_request_params,
@@ -40,6 +36,7 @@
zlib_compress,
zlib_decompress_to_string,
)
+from superset.utils.json import base_json_conv, json_int_dttm_ser, json_iso_dttm_ser
def mock_parse_human_datetime(s):
@@ -509,15 +506,6 @@ def test_datetime_f(self):
datetime_f(datetime(a, b, c)), '00:00:00 ',
)
- def test_json_encoded_obj(self):
- obj = {'a': 5, 'b': ['a', 'g', 5]}
- val = '{"a": 5, "b": ["a", "g", 5]}'
- jsonObj = JSONEncodedDict()
- resp = jsonObj.process_bind_param(obj, 'dialect')
- self.assertIn('"a": 5', resp)
- self.assertIn('"b": ["a", "g", 5]', resp)
- self.assertEquals(jsonObj.process_result_value(val, 'dialect'), obj)
-
def test_validate_json(self):
invalid = '{"a": 5, "b": [1, 5, ["g", "h]]}'
with self.assertRaises(SupersetException):