From 76dda688b1ea6297dbb0549402b8d34726f58151 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 13 Jul 2017 15:16:39 -0700 Subject: [PATCH 01/59] Using yarn instead of npm install (#3120) --- .travis.yml | 4 ++-- superset/assets/js_build.sh | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index dcf700fd0c8c..914c4b63b448 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ cache: env: global: - TRAVIS_CACHE=$HOME/.travis_cache/ - - TRAVIS_NODE_VERSION="6.10.2" + - TRAVIS_NODE_VERSION="7.10.0" matrix: - TOX_ENV=javascript - TOX_ENV=pylint @@ -19,7 +19,7 @@ env: - TOX_ENV=py27-mysql - TOX_ENV=py27-sqlite before_install: - - npm install -g npm@'>=4.5.0' + - npm install -g npm@'>=5.0.3' before_script: - mysql -e 'drop database if exists superset; create database superset DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci' -u root - mysql -u root -e "CREATE USER 'mysqluser'@'localhost' IDENTIFIED BY 'mysqluserpassword';" diff --git a/superset/assets/js_build.sh b/superset/assets/js_build.sh index 3231d915483c..7e48caa126d7 100755 --- a/superset/assets/js_build.sh +++ b/superset/assets/js_build.sh @@ -3,7 +3,8 @@ set -e cd "$(dirname "$0")" npm --version node --version -npm install +npm install -g yarn +yarn npm run sync-backend npm run lint npm run test From a626f994bf6550a6587a82a0c304aa9f8731a6e9 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 13 Jul 2017 15:53:20 -0700 Subject: [PATCH 02/59] [CLI] Improve the missing perm creation logic (#3118) I don't think this worked as intended --- superset/security.py | 49 +++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/superset/security.py b/superset/security.py index 3db268be0333..5878ea0ba2b4 100644 --- a/superset/security.py +++ b/superset/security.py @@ -157,41 +157,39 @@ def create_custom_permissions(): merge_perm(sm, 'all_database_access', 'all_database_access') -def create_missing_datasource_perms(view_menu_set): +def create_missing_perms(): + """Creates missing perms for datasources, schemas and metrics""" + + logging.info( + "Fetching a set of all perms to lookup which ones are missing") + all_pvs = set() + for pv in sm.get_session.query(sm.permissionview_model).all(): + all_pvs.add((pv.permission.name, pv.view_menu.name)) + + def merge_pv(view_menu, perm): + """Create permission view menu only if it doesn't exist""" + if view_menu and perm and (view_menu, perm) not in all_pvs: + merge_perm(sm, view_menu, perm) + logging.info("Creating missing datasource permissions.") - datasources = ConnectorRegistry.get_all_datasources( - db.session) + datasources = ConnectorRegistry.get_all_datasources(db.session) for datasource in datasources: - if datasource and datasource.perm not in view_menu_set: - merge_perm(sm, 'datasource_access', datasource.get_perm()) - if datasource.schema_perm: - merge_perm(sm, 'schema_access', datasource.schema_perm) - + merge_pv('datasource_access', datasource.get_perm()) + merge_pv('schema_access', datasource.schema_perm) -def create_missing_database_perms(view_menu_set): logging.info("Creating missing database permissions.") databases = db.session.query(models.Database).all() for database in databases: - if database and database.perm not in view_menu_set: - merge_perm(sm, 'database_access', database.perm) + merge_pv('database_access', database.perm) - -def create_missing_metrics_perm(view_menu_set): - """Create permissions for restricted metrics - - :param metrics: a list of metrics to be processed, if not specified, - all metrics are processed - :type metrics: models.SqlMetric or models.DruidMetric - """ logging.info("Creating missing metrics permissions") metrics = [] for datasource_class in ConnectorRegistry.sources.values(): metrics += list(db.session.query(datasource_class.metric_class).all()) for metric in metrics: - if (metric.is_restricted and metric.perm and - metric.perm not in view_menu_set): - merge_perm(sm, 'metric_access', metric.perm) + if (metric.is_restricted): + merge_pv('metric_access', metric.perm) def sync_role_definitions(): @@ -220,12 +218,7 @@ def sync_role_definitions(): if conf.get('PUBLIC_ROLE_LIKE_GAMMA', False): set_role('Public', pvms, is_gamma_pvm) - view_menu_set = [] - for datasource_class in ConnectorRegistry.sources.values(): - view_menu_set += list(db.session.query(datasource_class).all()) - create_missing_datasource_perms(view_menu_set) - create_missing_database_perms(view_menu_set) - create_missing_metrics_perm(view_menu_set) + create_missing_perms() # commit role and view menu updates sm.get_session.commit() From 256a521bf1083222f16ecbc02ac3d01522ebc9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=81=E6=A1=82=E6=B6=9B?= Date: Sat, 15 Jul 2017 00:44:41 +0800 Subject: [PATCH 03/59] [Celery] fix the celery worker concurrency settings (#3126) --- superset/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/cli.py b/superset/cli.py index 46b0ca794be7..f6163bb140b0 100755 --- a/superset/cli.py +++ b/superset/cli.py @@ -192,7 +192,7 @@ def worker(workers): celery_app.conf.update(CELERYD_CONCURRENCY=workers) elif config.get("SUPERSET_CELERY_WORKERS"): celery_app.conf.update( - worker_concurrency=config.get("SUPERSET_CELERY_WORKERS")) + CELERYD_CONCURRENCY=config.get("SUPERSET_CELERY_WORKERS")) worker = celery_worker.worker(app=celery_app) worker.run() From e834154030c5c554443ea73770dfbf6c965a826c Mon Sep 17 00:00:00 2001 From: Ke Zhu Date: Sat, 15 Jul 2017 12:52:38 -0400 Subject: [PATCH 04/59] Fixes #3134 by correct response content-type of /testconn (#3135) --- superset/views/core.py | 2 +- tests/core_tests.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/superset/views/core.py b/superset/views/core.py index 8a5de069efe1..dec49edd23a3 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -1374,7 +1374,7 @@ def testconn(self): .get('connect_args', {})) engine = create_engine(uri, connect_args=connect_args) engine.connect() - return json.dumps(engine.table_names(), indent=4) + return json_success(json.dumps(engine.table_names(), indent=4)) except Exception as e: logging.exception(e) return json_error_response(( diff --git a/tests/core_tests.py b/tests/core_tests.py index 7ff48a9081a8..f3e98873a63f 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -284,6 +284,7 @@ def test_testconn(self): }) response = self.client.post('/superset/testconn', data=data, content_type='application/json') assert response.status_code == 200 + assert response.headers['Content-Type'] == 'application/json' # validate that the endpoint works with the decrypted sqlalchemy uri data = json.dumps({ @@ -292,6 +293,7 @@ def test_testconn(self): }) response = self.client.post('/superset/testconn', data=data, content_type='application/json') assert response.status_code == 200 + assert response.headers['Content-Type'] == 'application/json' def test_databaseview_edit(self, username='admin'): # validate that sending a password-masked uri does not over-write the decrypted uri From 7b015faae9848bcbce7fa1ea85b43ba515d32e8a Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Sat, 15 Jul 2017 20:07:57 -0700 Subject: [PATCH 05/59] [heatmap] basic non empty validation (#3119) --- superset/assets/javascripts/explore/stores/visTypes.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 85aeff6e59a6..68991e35babf 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -798,6 +798,14 @@ const visTypes = { ], }, ], + controlOverrides: { + all_columns_x: { + validators: [v.nonEmpty], + }, + all_columns_y: { + validators: [v.nonEmpty], + }, + }, }, horizon: { From 7abe2d5eee177b854342c68cd218f37543ea4026 Mon Sep 17 00:00:00 2001 From: "Shao-Yen \"Fred\" Cheng" Date: Sun, 16 Jul 2017 21:04:34 -0700 Subject: [PATCH 06/59] [#3137] Use state.datasource.type instead of state.datasource_type when rendering ControlPanelsContainer (#3138) --- .../assets/javascripts/explore/components/ChartContainer.jsx | 2 +- .../javascripts/explore/components/ExploreViewContainer.jsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/superset/assets/javascripts/explore/components/ChartContainer.jsx b/superset/assets/javascripts/explore/components/ChartContainer.jsx index f6da538843ee..fca1afd33925 100644 --- a/superset/assets/javascripts/explore/components/ChartContainer.jsx +++ b/superset/assets/javascripts/explore/components/ChartContainer.jsx @@ -341,7 +341,7 @@ function mapStateToProps(state) { table_name: formData.datasource_name, viz_type: formData.viz_type, triggerRender: state.triggerRender, - datasourceType: state.datasource_type, + datasourceType: state.datasource.type, datasourceId: state.datasource_id, }; } diff --git a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx index 67141241a362..f015aa99b6a0 100644 --- a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx +++ b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx @@ -187,7 +187,7 @@ function mapStateToProps(state) { const form_data = getFormDataFromControls(state.controls); return { chartStatus: state.chartStatus, - datasource_type: state.datasource_type, + datasource_type: state.datasource.type, controls: state.controls, form_data, standalone: state.standalone, From bb6b2da26749131f0160268ff0d71d0fe1f8a267 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Sun, 16 Jul 2017 22:02:22 -0700 Subject: [PATCH 07/59] Prevent SQLA warning related to SQLALCHEMY_TRACK_MODIFICATION (#3133) --- superset/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/config.py b/superset/config.py index 61b111bde576..6c38fa2f76b5 100644 --- a/superset/config.py +++ b/superset/config.py @@ -46,6 +46,7 @@ SUPERSET_WEBSERVER_TIMEOUT = 60 EMAIL_NOTIFICATIONS = False CUSTOM_SECURITY_MANAGER = None +SQLALCHEMY_TRACK_MODIFICATIONS = False # --------------------------------------------------------- # Your App secret key From 091e93c8314ea3bd203bc29a8e3a6c9695ee46b7 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Sun, 16 Jul 2017 22:29:30 -0700 Subject: [PATCH 08/59] [minor] change tooltip on 'Edit slice properties' (#3116) * [minor] change tooltip on 'Edit slice properties' * Upgrading npm version for travis * Bumping node version to most recent --- .../assets/javascripts/explore/components/ChartContainer.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/assets/javascripts/explore/components/ChartContainer.jsx b/superset/assets/javascripts/explore/components/ChartContainer.jsx index fca1afd33925..fb0a34284033 100644 --- a/superset/assets/javascripts/explore/components/ChartContainer.jsx +++ b/superset/assets/javascripts/explore/components/ChartContainer.jsx @@ -272,7 +272,7 @@ class ChartContainer extends React.PureComponent { Date: Mon, 17 Jul 2017 08:58:10 -0700 Subject: [PATCH 09/59] allow user press Enter key to end editing title (#3112) --- superset/assets/javascripts/components/EditableTitle.jsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/superset/assets/javascripts/components/EditableTitle.jsx b/superset/assets/javascripts/components/EditableTitle.jsx index 70046f87ca1e..9d71388828ad 100644 --- a/superset/assets/javascripts/components/EditableTitle.jsx +++ b/superset/assets/javascripts/components/EditableTitle.jsx @@ -23,6 +23,7 @@ class EditableTitle extends React.PureComponent { this.handleClick = this.handleClick.bind(this); this.handleBlur = this.handleBlur.bind(this); this.handleChange = this.handleChange.bind(this); + this.handleKeyPress = this.handleKeyPress.bind(this); } handleClick() { if (!this.props.canEdit) { @@ -58,6 +59,13 @@ class EditableTitle extends React.PureComponent { title: ev.target.value, }); } + handleKeyPress(ev) { + if (ev.key === 'Enter') { + ev.preventDefault(); + + this.handleBlur(); + } + } render() { return ( @@ -72,6 +80,7 @@ class EditableTitle extends React.PureComponent { onChange={this.handleChange} onBlur={this.handleBlur} onClick={this.handleClick} + onKeyPress={this.handleKeyPress} /> From d7e419127c71c97580b00c8dae5fc14eb054a312 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 18 Jul 2017 19:42:20 -0700 Subject: [PATCH 10/59] [bugfix] fails on None view_menu (#3155) * [bugfix] fails on None view_menu * Update coveralls token --- .coveralls.yml | 2 +- superset/security.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.coveralls.yml b/.coveralls.yml index 273f84b694e7..e916d8ed05cc 100644 --- a/.coveralls.yml +++ b/.coveralls.yml @@ -1 +1 @@ -repo_token: eESbYiv4An6KEvjpmguDs4L7YkubXbqn1 +repo_token: 4P9MpvLrZfJKzHdGZsdV3MzO43OZJgYFn diff --git a/superset/security.py b/superset/security.py index 5878ea0ba2b4..9a01d6b652ef 100644 --- a/superset/security.py +++ b/superset/security.py @@ -164,7 +164,8 @@ def create_missing_perms(): "Fetching a set of all perms to lookup which ones are missing") all_pvs = set() for pv in sm.get_session.query(sm.permissionview_model).all(): - all_pvs.add((pv.permission.name, pv.view_menu.name)) + if pv.permission and pv.view_menu: + all_pvs.add((pv.permission.name, pv.view_menu.name)) def merge_pv(view_menu, perm): """Create permission view menu only if it doesn't exist""" From 51f1aa31066b3fbf59b23d2ad2f98d0d6195643b Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 19 Jul 2017 01:17:56 -0700 Subject: [PATCH 11/59] [docs] use yarn in CONTRIBUTING.md (#3150) * [docs] use yarn in CONTRIBUTING.md * Updating badges --- CONTRIBUTING.md | 9 +++++++-- README.md | 13 +++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d909d5d4a1c2..cd45739c031e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -223,8 +223,13 @@ To install third party libraries defined in `package.json`, run the following within the `superset/assets/` directory which will install them in a new `node_modules/` folder within `assets/`. -``` -npm install +```bash +# from the root of the repository, move to where our JS package.json lives +cd superset/assets/ +# install yarn, a replacement for `npm install` that is faster and more deterministic +npm install -g yarn +# run yarn to fetch all the dependencies +yarn ``` To parse and generate bundled files for superset, run either of the diff --git a/README.md b/README.md index 86e8db159ebf..3eda11eb6842 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@ Superset ========= -[![Build Status](https://travis-ci.org/airbnb/superset.svg?branch=master)](https://travis-ci.org/airbnb/superset) +[![Build Status](https://travis-ci.org/apache/incubator-superset.svg?branch=master)](https://travis-ci.org/apache/incubator-superset) [![PyPI version](https://badge.fury.io/py/superset.svg)](https://badge.fury.io/py/superset) -[![Coverage Status](https://coveralls.io/repos/airbnb/superset/badge.svg?branch=master&service=github)](https://coveralls.io/github/airbnb/superset?branch=master) -[![JS Test Coverage](https://codeclimate.com/github/airbnb/superset/badges/coverage.svg)](https://codeclimate.com/github/airbnb/superset/coverage) -[![Code Health](https://landscape.io/github/airbnb/superset/master/landscape.svg?style=flat)](https://landscape.io/github/airbnb/superset/master) -[![Code Climate](https://codeclimate.com/github/airbnb/superset/badges/gpa.svg)](https://codeclimate.com/github/airbnb/superset) +[![Coverage Status](https://coveralls.io/repos/apache/incubator-superset/badge.svg?branch=master&service=github)](https://coveralls.io/github/apache/incubator-superset?branch=master) [![PyPI](https://img.shields.io/pypi/pyversions/superset.svg?maxAge=2592000)](https://pypi.python.org/pypi/superset) -[![Requirements Status](https://requires.io/github/airbnb/superset/requirements.svg?branch=master)](https://requires.io/github/airbnb/superset/requirements/?branch=master) -[![Join the chat at https://gitter.im/airbnb/superset](https://badges.gitter.im/airbnb/superset.svg)](https://gitter.im/airbnb/superset?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Requirements Status](https://requires.io/github/apache/incubator-superset/requirements.svg?branch=master)](https://requires.io/github/apache/incubator-superset/requirements/?branch=master) +[![Join the chat at https://gitter.im/apache/incubator-superset](https://badges.gitter.im/apache/incubator-superset.svg)](https://gitter.im/apache/incubator-superset?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Documentation](https://img.shields.io/badge/docs-apache.org-blue.svg)](https://superset.incubator.apache.org) -[![dependencies Status](https://david-dm.org/airbnb/superset/status.svg?path=superset/assets)](https://david-dm.org/airbnb/superset?path=superset/assets) +[![dependencies Status](https://david-dm.org/apache/incubator-superset/status.svg?path=superset/assets)](https://david-dm.org/apache/incubator-superset?path=superset/assets) Date: Wed, 19 Jul 2017 16:18:34 +0800 Subject: [PATCH 12/59] add douban to the orgs. (#3157) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 3eda11eb6842..11b66752de4d 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ the world know they are using Superset. Join our growing community! - [Brilliant.org](https://brilliant.org/) - [Clark.de](http://clark.de/) - [Digit Game Studios](https://www.digitgaming.com/) + - [Douban](https://www.douban.com/) - [Endress+Hauser](http://www.endress.com/) - [FBK - ICT center](http://ict.fbk.eu) - [Faasos](http://faasos.com/) From c34df3eea40eacc59097fba14fab395bb0cd5989 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 19 Jul 2017 01:35:00 -0700 Subject: [PATCH 13/59] [bugfix] SQLA instance has been deleted (#3159) Related Msg: sqlalchemy.orm.exc.ObjectDeletedError: Instance '' has been deleted, or its row is otherwise not present. --- superset/security.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/superset/security.py b/superset/security.py index 9a01d6b652ef..012891143e45 100644 --- a/superset/security.py +++ b/superset/security.py @@ -141,12 +141,14 @@ def is_granter_pvm(pvm): 'can_approve'} -def set_role(role_name, pvms, pvm_check): +def set_role(role_name, pvm_check): logging.info("Syncing {} perms".format(role_name)) + sesh = sm.get_session() + pvms = sesh.query(ab_models.PermissionView).all() + pvms = [p for p in pvms if p.permission and p.view_menu] role = sm.add_role(role_name) role_pvms = [p for p in pvms if pvm_check(p)] role.permissions = role_pvms - sesh = sm.get_session() sesh.merge(role) sesh.commit() @@ -200,24 +202,15 @@ def sync_role_definitions(): get_or_create_main_db() create_custom_permissions() - pvms = db.session.query(ab_models.PermissionView).all() - pvms = [p for p in pvms if p.permission and p.view_menu] - - # cleanup - pvms_to_delete = [p for p in pvms if not (p.permission and p.view_menu)] - - for pvm_to_delete in pvms_to_delete: - sm.get_session.delete(pvm_to_delete) - # Creating default roles - set_role('Admin', pvms, is_admin_pvm) - set_role('Alpha', pvms, is_alpha_pvm) - set_role('Gamma', pvms, is_gamma_pvm) - set_role('granter', pvms, is_granter_pvm) - set_role('sql_lab', pvms, is_sql_lab_pvm) + set_role('Admin', is_admin_pvm) + set_role('Alpha', is_alpha_pvm) + set_role('Gamma', is_gamma_pvm) + set_role('granter', is_granter_pvm) + set_role('sql_lab', is_sql_lab_pvm) if conf.get('PUBLIC_ROLE_LIKE_GAMMA', False): - set_role('Public', pvms, is_gamma_pvm) + set_role('Public', is_gamma_pvm) create_missing_perms() From d01e67a159ccfcefd748e31922715a74bc572a9e Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 19 Jul 2017 15:59:30 -0700 Subject: [PATCH 14/59] More logging to csv endpoint (#3164) --- superset/views/core.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/superset/views/core.py b/superset/views/core.py index dec49edd23a3..06a0c1397a7f 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -2061,6 +2061,7 @@ def sql_json(self): @log_this def csv(self, client_id): """Download the query results as csv.""" + logging.info("Exporting CSV file [{}]".format(client_id)) query = ( db.session.query(Query) .filter_by(client_id=client_id) @@ -2074,14 +2075,20 @@ def csv(self, client_id): return redirect('/') blob = None if results_backend and query.results_key: + logging.info( + "Fetching CSV from results backend " + "[{}]".format(query.results_key)) blob = results_backend.get(query.results_key) if blob: + logging.info("Decompressing") json_payload = utils.zlib_decompress_to_string(blob) obj = json.loads(json_payload) columns = [c['name'] for c in obj['columns']] df = pd.DataFrame.from_records(obj['data'], columns=columns) + logging.info("Using pandas to convert to CSV") csv = df.to_csv(index=False, encoding='utf-8') else: + logging.info("Running a query to turn into CSV") sql = query.select_sql or query.executed_sql df = query.database.get_df(sql, query.schema) # TODO(bkyryliuk): add compression=gzip for big files. @@ -2089,6 +2096,7 @@ def csv(self, client_id): response = Response(csv, mimetype='text/csv') response.headers['Content-Disposition'] = ( 'attachment; filename={}.csv'.format(query.name)) + logging.info("Ready to return response") return response @has_access From a141695b2b73df1d05d74c8ed8e22341e6e25edc Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 19 Jul 2017 16:24:20 -0700 Subject: [PATCH 15/59] Prevent people from deleting datasources that have associate slices (#3163) --- superset/connectors/base/__init__.py | 0 superset/connectors/{base.py => base/models.py} | 0 superset/connectors/base/views.py | 12 ++++++++++++ superset/connectors/druid/models.py | 2 +- superset/connectors/druid/views.py | 8 ++++---- superset/connectors/sqla/models.py | 5 ++--- superset/connectors/sqla/views.py | 5 +++-- 7 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 superset/connectors/base/__init__.py rename superset/connectors/{base.py => base/models.py} (100%) create mode 100644 superset/connectors/base/views.py diff --git a/superset/connectors/base/__init__.py b/superset/connectors/base/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/superset/connectors/base.py b/superset/connectors/base/models.py similarity index 100% rename from superset/connectors/base.py rename to superset/connectors/base/models.py diff --git a/superset/connectors/base/views.py b/superset/connectors/base/views.py new file mode 100644 index 000000000000..36cfe4507640 --- /dev/null +++ b/superset/connectors/base/views.py @@ -0,0 +1,12 @@ +from superset.views.base import SupersetModelView +from superset.utils import SupersetException +from flask import Markup + + +class DatasourceModelView(SupersetModelView): + def pre_delete(self, obj): + if obj.slices: + raise SupersetException(Markup( + "Cannot delete a datasource that has slices attached to it." + "Here's the list of associated slices: " + + "".join([o.slice_link for o in obj.slices]))) diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index dc037264a1c3..b4e1556a9eaa 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -33,7 +33,7 @@ from superset.utils import ( flasher, MetricPermException, DimSelector, DTTM_ALIAS ) -from superset.connectors.base import BaseDatasource, BaseColumn, BaseMetric +from superset.connectors.base.models import BaseDatasource, BaseColumn, BaseMetric from superset.models.helpers import AuditMixinNullable, QueryResult, set_perm DRUID_TZ = conf.get("DRUID_TZ") diff --git a/superset/connectors/druid/views.py b/superset/connectors/druid/views.py index e12321755b24..7ae3fb37096a 100644 --- a/superset/connectors/druid/views.py +++ b/superset/connectors/druid/views.py @@ -3,19 +3,19 @@ import sqlalchemy as sqla -from flask import Markup, flash, redirect, abort +from flask import Markup, flash, redirect from flask_appbuilder import CompactCRUDMixin, expose from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import lazy_gettext as _ from flask_babel import gettext as __ -import superset from superset import db, utils, appbuilder, sm, security from superset.connectors.connector_registry import ConnectorRegistry from superset.utils import has_access -from superset.views.base import BaseSupersetView +from superset.connectors.base.views import DatasourceModelView from superset.views.base import ( + BaseSupersetView, SupersetModelView, validate_json, DeleteMixin, ListWidgetWithCheckboxes, DatasourceFilter, get_datasource_exist_error_mgs) @@ -149,7 +149,7 @@ def _delete(self, pk): category_icon='fa-database',) -class DruidDatasourceModelView(SupersetModelView, DeleteMixin): # noqa +class DruidDatasourceModelView(DatasourceModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.DruidDatasource) list_widget = ListWidgetWithCheckboxes list_columns = [ diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 1cd0818d0706..147c667df04b 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -11,8 +11,7 @@ ) import sqlalchemy as sa from sqlalchemy import asc, and_, desc, select -from sqlalchemy.ext.compiler import compiles -from sqlalchemy.sql.expression import ColumnClause, TextAsFrom +from sqlalchemy.sql.expression import TextAsFrom from sqlalchemy.orm import backref, relationship from sqlalchemy.sql import table, literal_column, text, column @@ -21,7 +20,7 @@ from flask_babel import lazy_gettext as _ from superset import db, utils, import_util, sm -from superset.connectors.base import BaseDatasource, BaseColumn, BaseMetric +from superset.connectors.base.models import BaseDatasource, BaseColumn, BaseMetric from superset.utils import DTTM_ALIAS, QueryStatus from superset.models.helpers import QueryResult from superset.models.core import Database diff --git a/superset/connectors/sqla/views.py b/superset/connectors/sqla/views.py index 5ba10dac54f4..cddd859dac10 100644 --- a/superset/connectors/sqla/views.py +++ b/superset/connectors/sqla/views.py @@ -3,7 +3,7 @@ from past.builtins import basestring -from flask import Markup, flash, redirect, abort +from flask import Markup, flash, redirect from flask_appbuilder import CompactCRUDMixin, expose from flask_appbuilder.models.sqla.interface import SQLAInterface import sqlalchemy as sa @@ -13,6 +13,7 @@ from superset import appbuilder, db, utils, security, sm from superset.utils import has_access +from superset.connectors.base.views import DatasourceModelView from superset.views.base import ( SupersetModelView, ListWidgetWithCheckboxes, DeleteMixin, DatasourceFilter, get_datasource_exist_error_mgs, @@ -133,7 +134,7 @@ def post_update(self, metric): appbuilder.add_view_no_menu(SqlMetricInlineView) -class TableModelView(SupersetModelView, DeleteMixin): # noqa +class TableModelView(DatasourceModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.SqlaTable) list_columns = [ 'link', 'database', From 40d9e15126121b96fbc1a90e10f6721eb5b47942 Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Fri, 21 Jul 2017 16:29:25 -0700 Subject: [PATCH 16/59] Add event-flow visualization (#3102) * [event-flow] add event flow visualizaton type from @data-ui/event-flow. * [event-flow] update vis thumbnail * [event-flow] update row limit label, remove duplicate chart controls * [dependencies] add @data-ui/event-flow 0.0.2 * [linting] fix multiple imports * [deps] bump mapbox-gl and react-map-gl to fix build * [event-flow] bump to 0.0.3 for es2015 + stage-0 babel presets * [deps] revert mapbox version bumps * [event-flow] update png, bump to newest version, address reviewer comments, add min event count form. * [event-flow] pin version * [event-flow][spec] add test for coveralls * [event-flow] revert spec --- .../images/viz_thumbnails/event_flow.png | Bin 0 -> 108626 bytes .../javascripts/explore/stores/controls.jsx | 20 +++++- .../javascripts/explore/stores/visTypes.js | 46 ++++++++++++- superset/assets/package.json | 1 + superset/assets/visualizations/EventFlow.jsx | 61 ++++++++++++++++++ superset/assets/visualizations/main.js | 1 + superset/assets/visualizations/treemap.css | 24 +++---- superset/assets/visualizations/treemap.js | 1 + superset/viz.py | 30 +++++++++ 9 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 superset/assets/images/viz_thumbnails/event_flow.png create mode 100644 superset/assets/visualizations/EventFlow.jsx diff --git a/superset/assets/images/viz_thumbnails/event_flow.png b/superset/assets/images/viz_thumbnails/event_flow.png new file mode 100644 index 0000000000000000000000000000000000000000..45765295be00343953c6301a1d26f3cdcb1b2b51 GIT binary patch literal 108626 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 R;fcV%-C72kX*+Gs{})1d ({ choices: (state.datasource) ? state.datasource.gb_cols : [], }), @@ -1273,5 +1273,23 @@ export const controls = { hidden: true, description: 'The number of seconds before expiring the cache', }, + + order_by_entity: { + type: 'CheckboxControl', + label: 'Order by entity id', + description: 'Important! Select this if the table is not already sorted by entity id, ' + + 'else there is no guarantee that all events for each entity are returned.', + default: true, + }, + + min_leaf_node_event_count: { + type: 'SelectControl', + freeForm: false, + label: 'Minimum leaf node event count', + default: 1, + choices: formatSelectOptionsForRange(1, 10), + description: 'Leaf nodes that represent fewer than this number of events will be initially ' + + 'hidden in the visualization', + }, }; export default controls; diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 68991e35babf..bdebf076420a 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -1,5 +1,4 @@ import { D3_TIME_FORMAT_OPTIONS } from './controls'; - import * as v from '../validators'; export const sections = { @@ -890,6 +889,51 @@ const visTypes = { }, }, }, + + event_flow: { + label: 'Event flow', + requiresTime: true, + controlPanelSections: [ + { + label: 'Event definition', + controlSetRows: [ + ['entity'], + ['all_columns_x'], + ['row_limit'], + ['order_by_entity'], + ['min_leaf_node_event_count'], + ], + }, + { + label: 'Additional meta data', + controlSetRows: [ + ['all_columns'], + ], + }, + ], + controlOverrides: { + entity: { + label: 'Column containing entity ids', + description: 'e.g., a "user id" column', + }, + all_columns_x: { + label: 'Column containing event names', + validators: [v.nonEmpty], + default: control => ( + control.choices && control.choices.length > 0 ? + control.choices[0][0] : null + ), + }, + row_limit: { + label: 'Event count limit', + description: 'The maximum number of events to return, equivalent to number of rows', + }, + all_columns: { + label: 'Meta data', + description: 'Select any columns for meta data inspection', + }, + }, + }, }; export default visTypes; diff --git a/superset/assets/package.json b/superset/assets/package.json index 5d5022def10b..ff4c161ad6e1 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -38,6 +38,7 @@ }, "homepage": "https://github.com/airbnb/superset#readme", "dependencies": { + "@data-ui/event-flow": "0.0.4", "babel-register": "^6.24.1", "bootstrap": "^3.3.6", "brace": "^0.10.0", diff --git a/superset/assets/visualizations/EventFlow.jsx b/superset/assets/visualizations/EventFlow.jsx new file mode 100644 index 000000000000..110f4a76482c --- /dev/null +++ b/superset/assets/visualizations/EventFlow.jsx @@ -0,0 +1,61 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { + App, + withParentSize, + cleanEvents, + TS, + EVENT_NAME, + ENTITY_ID, +} from '@data-ui/event-flow'; + +/* + * This function takes the slice object and json payload as input and renders a + * responsive component using the json data. + */ +function renderEventFlow(slice, json) { + const container = document.querySelector(slice.selector); + const hasData = json.data && json.data.length > 0; + + // the slice container overflows ~80px in explorer, so we have to correct for this + const isExplorer = (/explore/).test(window.location.pathname); + + const ResponsiveVis = withParentSize(({ + parentWidth, + parentHeight, + ...rest + }) => ( + + )); + + // render the component if we have data, otherwise render a no-data message + let Component; + if (hasData) { + const userKey = json.form_data.entity; + const eventNameKey = json.form_data.all_columns_x; + + // map from the Superset form fields to 's expected data keys + const accessorFunctions = { + [TS]: datum => new Date(datum.__timestamp), // eslint-disable-line no-underscore-dangle + [EVENT_NAME]: datum => datum[eventNameKey], + [ENTITY_ID]: datum => String(datum[userKey]), + }; + + const dirtyData = json.data; + const cleanData = cleanEvents(dirtyData, accessorFunctions); + const minEventCount = slice.formData.min_leaf_node_event_count; + + Component = ; + } else { + Component =
Sorry, there appears to be no data
; + } + + ReactDOM.render(Component, container); +} + +module.exports = renderEventFlow; diff --git a/superset/assets/visualizations/main.js b/superset/assets/visualizations/main.js index 68abddf5e353..a02f508c3301 100644 --- a/superset/assets/visualizations/main.js +++ b/superset/assets/visualizations/main.js @@ -32,5 +32,6 @@ const vizMap = { word_cloud: require('./word_cloud.js'), world_map: require('./world_map.js'), dual_line: require('./nvd3_vis.js'), + event_flow: require('./EventFlow.jsx'), }; export default vizMap; diff --git a/superset/assets/visualizations/treemap.css b/superset/assets/visualizations/treemap.css index c385780c82a7..2fdcdc76d7e5 100644 --- a/superset/assets/visualizations/treemap.css +++ b/superset/assets/visualizations/treemap.css @@ -1,43 +1,43 @@ -text { +.treemap text { pointer-events: none; } -.grandparent text { +.treemap .grandparent text { font-weight: bold; } -rect { +.treemap rect { fill: none; stroke: #fff; } -rect.parent, -.grandparent rect { +.treemap rect.parent, +.treemap .grandparent rect { stroke-width: 2px; } -rect.parent { +.treemap rect.parent { pointer-events: none; } -.grandparent rect { +.treemap .grandparent rect { fill: #eee; } -.grandparent:hover rect { +.treemap .grandparent:hover rect { fill: #aaa; } -.children rect.parent, -.grandparent rect { +.treemap .children rect.parent, +.treemap .grandparent rect { cursor: pointer; } -.children rect.parent { +.treemap .children rect.parent { fill: #bbb; fill-opacity: .5; } -.children:hover rect.child { +.treemap .children:hover rect.child { fill: #bbb; } diff --git a/superset/assets/visualizations/treemap.js b/superset/assets/visualizations/treemap.js index 1e025935e6b2..f728985dba5c 100644 --- a/superset/assets/visualizations/treemap.js +++ b/superset/assets/visualizations/treemap.js @@ -34,6 +34,7 @@ function treemap(slice, payload) { .round(false); const svg = div.append('svg') + .attr('class', 'treemap') .attr('width', eltWidth) .attr('height', eltHeight); diff --git a/superset/viz.py b/superset/viz.py index a8cf3bfe5b60..1ae42b369aab 100755 --- a/superset/viz.py +++ b/superset/viz.py @@ -1587,6 +1587,35 @@ def get_data(self, df): "color": fd.get("mapbox_color"), } +class EventFlowViz(BaseViz): + """A visualization to explore patterns in event sequences""" + + viz_type = "event_flow" + verbose_name = _("Event flow") + credits = 'from
@data-ui' + is_timeseries = True + + def query_obj(self): + query = super(EventFlowViz, self).query_obj() + form_data = self.form_data + + event_key = form_data.get('all_columns_x') + entity_key = form_data.get('entity') + meta_keys = [ + col for col in form_data.get('all_columns') if col != event_key and col != entity_key + ] + + query['columns'] = [event_key, entity_key] + meta_keys + + if form_data['order_by_entity']: + query['orderby'] = [(entity_key, True)] + + return query + + def get_data(self, df): + return df.to_dict(orient="records") + + viz_types_list = [ TableViz, @@ -1621,6 +1650,7 @@ def get_data(self, df): MapboxViz, HistogramViz, SeparatorViz, + EventFlowViz, ] viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list From 49ab09101b00e4981b35875dda4d7da154222bd0 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 25 Jul 2017 13:57:29 -0700 Subject: [PATCH 17/59] Fixing the damn build (#3179) * Fixing the build * Going deeper --- superset/assets/javascripts/modules/superset.js | 2 +- superset/assets/package.json | 3 +-- superset/assets/visualizations/treemap.js | 2 +- superset/assets/webpack.config.js | 13 +++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/superset/assets/javascripts/modules/superset.js b/superset/assets/javascripts/modules/superset.js index 55af823117c4..eccdda4a312f 100644 --- a/superset/assets/javascripts/modules/superset.js +++ b/superset/assets/javascripts/modules/superset.js @@ -8,7 +8,7 @@ import { QUERY_TIMEOUT_THRESHOLD } from '../constants'; const utils = require('./utils'); -/* eslint wrap-iife: 0*/ +/* eslint wrap-iife: 0 */ const px = function () { let slice; function getParam(name) { diff --git a/superset/assets/package.json b/superset/assets/package.json index ff4c161ad6e1..055d9fd0fd1b 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -57,7 +57,6 @@ "jquery": "^3.2.1", "jsdom": "9.12.0", "lodash.throttle": "^4.1.1", - "mapbox-gl": "^0.26.0", "moment": "^2.14.1", "mustache": "^2.2.1", "nvd3": "1.8.5", @@ -72,7 +71,7 @@ "react-dom": "^15.5.1", "react-gravatar": "^2.6.1", "react-grid-layout": "^0.14.4", - "react-map-gl": "^1.7.0", + "react-map-gl": "^2.0.3", "react-redux": "^5.0.2", "react-resizable": "^1.3.3", "react-select": "1.0.0-rc.3", diff --git a/superset/assets/visualizations/treemap.js b/superset/assets/visualizations/treemap.js index f728985dba5c..3243dba47228 100644 --- a/superset/assets/visualizations/treemap.js +++ b/superset/assets/visualizations/treemap.js @@ -1,4 +1,4 @@ -/* eslint-disable no-shadow, no-param-reassign, no-underscore-dangle, no-use-before-define*/ +/* eslint-disable no-shadow, no-param-reassign, no-underscore-dangle, no-use-before-define */ import d3 from 'd3'; import { category21 } from '../javascripts/modules/colors'; diff --git a/superset/assets/webpack.config.js b/superset/assets/webpack.config.js index be8c8c983b9c..6b80952c9093 100644 --- a/superset/assets/webpack.config.js +++ b/superset/assets/webpack.config.js @@ -10,6 +10,9 @@ const APP_DIR = path.resolve(__dirname, './'); const BUILD_DIR = path.resolve(__dirname, './dist'); const config = { + node: { + fs: 'empty', + }, entry: { 'css-theme': APP_DIR + '/javascripts/css-theme.js', common: APP_DIR + '/javascripts/common.js', @@ -32,9 +35,7 @@ const config = { ], alias: { webworkify: 'webworkify-webpack', - 'mapbox-gl/js/geo/transform': path.join( - __dirname, '/node_modules/mapbox-gl/js/geo/transform'), - 'mapbox-gl': path.join(__dirname, '/node_modules/mapbox-gl/dist/mapbox-gl.js'), + 'mapbox-gl$': path.join(__dirname, '/node_modules/mapbox-gl/dist/mapbox-gl.js'), }, }, @@ -57,10 +58,10 @@ const config = { ], }, }, - /* for react-map-gl overlays */ + /* for mapbox-gl/js/geo/transform */ { - test: /\.react\.js$/, - include: APP_DIR + '/node_modules/react-map-gl/src/overlays', + test: /\.js$/, + include: APP_DIR + '/node_modules/mapbox-gl/js', loader: 'babel-loader', }, /* for require('*.css') */ From 95509f20008684470a2c02ca1bb1295f395d4f29 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 25 Jul 2017 20:50:41 -0700 Subject: [PATCH 18/59] [bugfix] only filterable columns should show up in FilterBox list (#3105) * [bugfix] only filterable columns should show up in FilterBox list * Touchups --- .../explore/components/ControlPanelsContainer.jsx | 12 ++++++++++-- .../explore/components/controls/SelectControl.jsx | 3 ++- .../assets/javascripts/explore/stores/controls.jsx | 2 +- .../assets/javascripts/explore/stores/visTypes.js | 11 ++++++++--- superset/connectors/base/models.py | 4 +++- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx b/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx index 9227390d1251..ac82bb490c31 100644 --- a/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx +++ b/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { Alert } from 'react-bootstrap'; -import { sectionsToRender } from '../stores/visTypes'; +import { sectionsToRender, visTypes } from '../stores/visTypes'; import ControlPanelSection from './ControlPanelSection'; import ControlRow from './ControlRow'; import Control from './Control'; @@ -28,7 +28,15 @@ class ControlPanelsContainer extends React.Component { this.getControlData = this.getControlData.bind(this); } getControlData(controlName) { - const mapF = controls[controlName].mapStateToProps; + // Identifying mapStateToProps function to apply (logic can't be in store) + let mapF = controls[controlName].mapStateToProps; + + // Looking to find mapStateToProps override for this viz type + const controlOverrides = visTypes[this.props.controls.viz_type.value].controlOverrides || {}; + if (controlOverrides[controlName] && controlOverrides[controlName].mapStateToProps) { + mapF = controlOverrides[controlName].mapStateToProps; + } + // Applying mapStateToProps if needed if (mapF) { return Object.assign({}, this.props.controls[controlName], mapF(this.props.exploreState)); } diff --git a/superset/assets/javascripts/explore/components/controls/SelectControl.jsx b/superset/assets/javascripts/explore/components/controls/SelectControl.jsx index 6998c071b0e9..312fced55bb3 100644 --- a/superset/assets/javascripts/explore/components/controls/SelectControl.jsx +++ b/superset/assets/javascripts/explore/components/controls/SelectControl.jsx @@ -43,7 +43,8 @@ export default class SelectControl extends React.PureComponent { this.onChange = this.onChange.bind(this); } componentWillReceiveProps(nextProps) { - if (nextProps.choices !== this.props.choices) { + if (nextProps.choices !== this.props.choices || + nextProps.options !== this.props.options) { const options = this.getOptions(nextProps); this.setState({ options }); } diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 13306932ce01..ecfbfe9e6425 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -326,7 +326,7 @@ export const controls = { valueRenderer: c => , valueKey: 'column_name', mapStateToProps: state => ({ - options: (state.datasource) ? state.datasource.columns : [], + options: (state.datasource) ? state.datasource.columns.filter(c => c.groupby) : [], }), }, diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index bdebf076420a..7d3f26284bbc 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -75,7 +75,7 @@ export const sections = { ], }; -const visTypes = { +export const visTypes = { dist_bar: { label: 'Distribution - Bar Chart', controlPanelSections: [ @@ -742,8 +742,13 @@ const visTypes = { controlOverrides: { groupby: { label: 'Filter controls', - description: 'The controls you want to filter on', - default: [], + description: ( + 'The controls you want to filter on. Note that only columns ' + + 'checked as "filterable" will show up on this list.' + ), + mapStateToProps: state => ({ + options: (state.datasource) ? state.datasource.columns.filter(c => c.filterable) : [], + }), }, }, }, diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index e203ef440120..b32bb928a3c3 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -222,7 +222,9 @@ def expression(self): @property def data(self): - attrs = ('column_name', 'verbose_name', 'description', 'expression') + attrs = ( + 'column_name', 'verbose_name', 'description', 'expression', + 'filterable', 'groupby') return {s: getattr(self, s) for s in attrs} From b301ba1f57b884248def367126a9a76e256c46b8 Mon Sep 17 00:00:00 2001 From: Rogan Date: Wed, 26 Jul 2017 12:05:31 +0800 Subject: [PATCH 19/59] Datasource cannot be empty (#3035) --- superset/assets/javascripts/explore/stores/controls.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index ecfbfe9e6425..1fdf868df236 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -35,6 +35,7 @@ export const controls = { isLoading: true, clearable: false, default: null, + validators: [v.nonEmpty], mapStateToProps: (state) => { const datasources = state.datasources || []; return { From 7654eef11095f7210b680d43656d6cdfdf67b306 Mon Sep 17 00:00:00 2001 From: Rogan Date: Wed, 26 Jul 2017 12:06:34 +0800 Subject: [PATCH 20/59] add title description to model view (#3045) * add title description to model view * add missing import --- superset/connectors/druid/views.py | 24 ++++++++++++++++++++++++ superset/connectors/sqla/views.py | 18 ++++++++++++++++++ superset/views/core.py | 18 ++++++++++++++++++ superset/views/sql_lab.py | 7 +++++++ 4 files changed, 67 insertions(+) diff --git a/superset/connectors/druid/views.py b/superset/connectors/druid/views.py index 7ae3fb37096a..b9a87e3f5256 100644 --- a/superset/connectors/druid/views.py +++ b/superset/connectors/druid/views.py @@ -24,6 +24,12 @@ class DruidColumnInlineView(CompactCRUDMixin, SupersetModelView): # noqa datamodel = SQLAInterface(models.DruidColumn) + + list_title = _('List Druid Column') + show_title = _('Show Druid Column') + add_title = _('Add Druid Column') + edit_title = _('Edit Druid Column') + edit_columns = [ 'column_name', 'description', 'dimension_spec_json', 'datasource', 'groupby', 'filterable', 'count_distinct', 'sum', 'min', 'max'] @@ -70,6 +76,12 @@ def post_add(self, col): class DruidMetricInlineView(CompactCRUDMixin, SupersetModelView): # noqa datamodel = SQLAInterface(models.DruidMetric) + + list_title = _('List Druid Metric') + show_title = _('Show Druid Metric') + add_title = _('Add Druid Metric') + edit_title = _('Edit Druid Metric') + list_columns = ['metric_name', 'verbose_name', 'metric_type'] edit_columns = [ 'metric_name', 'description', 'verbose_name', 'metric_type', 'json', @@ -112,6 +124,12 @@ def post_update(self, metric): class DruidClusterModelView(SupersetModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.DruidCluster) + + list_title = _('List Druid Cluster') + show_title = _('Show Druid Cluster') + add_title = _('Add Druid Cluster') + edit_title = _('Edit Druid Cluster') + add_columns = [ 'verbose_name', 'coordinator_host', 'coordinator_port', 'coordinator_endpoint', 'broker_host', 'broker_port', @@ -151,6 +169,12 @@ def _delete(self, pk): class DruidDatasourceModelView(DatasourceModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.DruidDatasource) + + list_title = _('List Druid Datasource') + show_title = _('Show Druid Datasource') + add_title = _('Add Druid Datasource') + edit_title = _('Edit Druid Datasource') + list_widget = ListWidgetWithCheckboxes list_columns = [ 'datasource_link', 'cluster', 'changed_by_', 'modified'] diff --git a/superset/connectors/sqla/views.py b/superset/connectors/sqla/views.py index cddd859dac10..ef87d3140a1a 100644 --- a/superset/connectors/sqla/views.py +++ b/superset/connectors/sqla/views.py @@ -24,6 +24,12 @@ class TableColumnInlineView(CompactCRUDMixin, SupersetModelView): # noqa datamodel = SQLAInterface(models.TableColumn) + + list_title = _('List Columns') + show_title = _('Show Column') + add_title = _('Add Column') + edit_title = _('Edit Column') + can_delete = False list_widget = ListWidgetWithCheckboxes edit_columns = [ @@ -92,6 +98,12 @@ class TableColumnInlineView(CompactCRUDMixin, SupersetModelView): # noqa class SqlMetricInlineView(CompactCRUDMixin, SupersetModelView): # noqa datamodel = SQLAInterface(models.SqlMetric) + + list_title = _('List Metrics') + show_title = _('Show Metric') + add_title = _('Add Metric') + edit_title = _('Edit Metric') + list_columns = ['metric_name', 'verbose_name', 'metric_type'] edit_columns = [ 'metric_name', 'description', 'verbose_name', 'metric_type', @@ -136,6 +148,12 @@ def post_update(self, metric): class TableModelView(DatasourceModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.SqlaTable) + + list_title = _('List Tables') + show_title = _('Show Table') + add_title = _('Add Table') + edit_title = _('Edit Table') + list_columns = [ 'link', 'database', 'changed_by_', 'modified'] diff --git a/superset/views/core.py b/superset/views/core.py index 06a0c1397a7f..eded30904f4b 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -170,6 +170,12 @@ def generate_download_headers(extension): class DatabaseView(SupersetModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.Database) + + list_title = _('List Databases') + show_title = _('Show Database') + add_title = _('Add Database') + edit_title = _('Edit Database') + list_columns = [ 'database_name', 'backend', 'allow_run_sync', 'allow_run_async', 'allow_dml', 'creator', 'modified'] @@ -319,6 +325,12 @@ class AccessRequestsModelView(SupersetModelView, DeleteMixin): class SliceModelView(SupersetModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.Slice) + + list_title = _('List Slices') + show_title = _('Show Slice') + add_title = _('Add Slice') + edit_title = _('Edit Slice') + can_add = False label_columns = { 'datasource_link': 'Datasource', @@ -415,6 +427,12 @@ class SliceAddView(SliceModelView): # noqa class DashboardModelView(SupersetModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.Dashboard) + + list_title = _('List Dashboards') + show_title = _('Show Dashboard') + add_title = _('Add Dashboard') + edit_title = _('Edit Dashboard') + list_columns = ['dashboard_link', 'creator', 'modified'] edit_columns = [ 'dashboard_title', 'slug', 'slices', 'owners', 'position_json', 'css', diff --git a/superset/views/sql_lab.py b/superset/views/sql_lab.py index 16a8dd21c947..03f382e9cc79 100644 --- a/superset/views/sql_lab.py +++ b/superset/views/sql_lab.py @@ -4,6 +4,7 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface from flask_babel import gettext as __ +from flask_babel import lazy_gettext as _ from superset import appbuilder from superset.models.sql_lab import Query, SavedQuery @@ -25,6 +26,12 @@ class QueryView(SupersetModelView): class SavedQueryView(SupersetModelView, DeleteMixin): datamodel = SQLAInterface(SavedQuery) + + list_title = _('List Saved Query') + show_title = _('Show Saved Query') + add_title = _('Add Saved Query') + edit_title = _('Edit Saved Query') + list_columns = [ 'label', 'user', 'database', 'schema', 'description', 'modified', 'pop_tab_link'] From 426851365349523cf10930c5d3be834228019a8b Mon Sep 17 00:00:00 2001 From: "Rich @ RadICS" Date: Wed, 26 Jul 2017 06:11:38 +0200 Subject: [PATCH 21/59] Add 'show/hide totals' option to pivot table vis (#3101) --- superset/assets/javascripts/explore/stores/controls.jsx | 8 ++++++++ superset/assets/javascripts/explore/stores/visTypes.js | 2 +- superset/viz.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 1fdf868df236..03d1fbddd59c 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -218,6 +218,14 @@ export const controls = { description: null, }, + pivot_margins: { + type: 'CheckboxControl', + label: 'Show totals', + renderTrigger: false, + default: true, + description: 'Display total row/column', + }, + show_markers: { type: 'CheckboxControl', label: 'Show Markers', diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 7d3f26284bbc..7291286a1f74 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -337,7 +337,7 @@ export const visTypes = { controlSetRows: [ ['groupby', 'columns'], ['metrics', 'pandas_aggfunc'], - ['number_format'], + ['number_format', 'pivot_margins'], ], }, ], diff --git a/superset/viz.py b/superset/viz.py index 1ae42b369aab..6778c3511ea0 100755 --- a/superset/viz.py +++ b/superset/viz.py @@ -394,7 +394,7 @@ def get_data(self, df): columns=self.form_data.get('columns'), values=self.form_data.get('metrics'), aggfunc=self.form_data.get('pandas_aggfunc'), - margins=True, + margins=self.form_data.get('pivot_margins'), ) return dict( columns=list(df.columns), From 0ec9cd4ad279c24b6d65fb825a319357958297fd Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 25 Jul 2017 21:30:40 -0700 Subject: [PATCH 22/59] [bugfix] numeric value for date fields in table viz (#3036) Bug was present only when using the NOT GROUPED BY option fixes https://github.com/ApacheInfra/superset/issues/3027 --- superset/viz.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/superset/viz.py b/superset/viz.py index 6778c3511ea0..19ff165e26a1 100755 --- a/superset/viz.py +++ b/superset/viz.py @@ -353,6 +353,12 @@ def get_data(self, df): columns=list(df.columns), ) + def json_dumps(self, obj): + if self.form_data.get('all_columns'): + return json.dumps(obj, default=utils.json_iso_dttm_ser) + else: + return super(TableViz, self).json_dumps(obj) + class PivotTableViz(BaseViz): From 6045063e786caef8a1692c0504d4cfb19e42ddde Mon Sep 17 00:00:00 2001 From: timfeirg Date: Wed, 26 Jul 2017 12:43:19 +0800 Subject: [PATCH 23/59] fix hive.fetch_logs (#2968) --- superset/db_engine_specs.py | 6 +++--- superset/db_engines/hive.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/superset/db_engine_specs.py b/superset/db_engine_specs.py index d4a7fa0e4de4..b460226c1aa6 100644 --- a/superset/db_engine_specs.py +++ b/superset/db_engine_specs.py @@ -723,9 +723,9 @@ def handle_cursor(cls, cursor, query, session): cursor.cancel() break - resp = cursor.fetch_logs() - if resp and resp.log: - progress = cls.progress(resp.log) + logs = cursor.fetch_logs() + if logs: + progress = cls.progress(logs) if progress > query.progress: query.progress = progress session.commit() diff --git a/superset/db_engines/hive.py b/superset/db_engines/hive.py index d3244feac62b..a31b4d7f323d 100644 --- a/superset/db_engines/hive.py +++ b/superset/db_engines/hive.py @@ -1,5 +1,6 @@ from pyhive import hive from pythrifthiveapi.TCLIService import ttypes +from thrift import Thrift # TODO: contribute back to pyhive. @@ -15,9 +16,11 @@ def fetch_logs(self, max_rows=1024, """ try: req = ttypes.TGetLogReq(operationHandle=self._operationHandle) - logs = self._connection.client.GetLog(req) + logs = self._connection.client.GetLog(req).log return logs - except ttypes.TApplicationException as e: # raised if Hive is used + # raised if Hive is used + except (ttypes.TApplicationException, + Thrift.TApplicationException): if self._state == self._STATE_NONE: raise hive.ProgrammingError("No query yet") logs = [] @@ -30,12 +33,11 @@ def fetch_logs(self, max_rows=1024, ) response = self._connection.client.FetchResults(req) hive._check_status(response) - assert not ( - response.results.rows, 'expected data in columnar format' - ) + assert not response.results.rows, \ + 'expected data in columnar format' assert len(response.results.columns) == 1, response.results.columns new_logs = hive._unwrap_column(response.results.columns[0]) logs += new_logs if not new_logs: break - return logs + return '\n'.join(logs) From 4f7fd65c8b76bee0b48760987e7475d7da063c45 Mon Sep 17 00:00:00 2001 From: Dmitry Goryunov Date: Wed, 26 Jul 2017 17:32:06 +0200 Subject: [PATCH 24/59] add Zalando to the list of organizations (#3171) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 11b66752de4d..d508f9e6baea 100644 --- a/README.md +++ b/README.md @@ -186,3 +186,4 @@ the world know they are using Superset. Join our growing community! - [Tooploox](https://www.tooploox.com/) - [Udemy](https://www.udemy.com/) - [Yahoo!](www.yahoo.com) + - [Zalando](https://www.zalando.com) From 747bf80474039d7d4c90fea72535e70b277e5a2c Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 26 Jul 2017 18:20:06 +0200 Subject: [PATCH 25/59] docs: fixup installation examples code indentation (#3169) --- docs/installation.rst | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 10aa7d77bbcc..c9b027ab4ca8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -392,13 +392,13 @@ have the same configuration. .. code-block:: python - class CeleryConfig(object): - BROKER_URL = 'redis://localhost:6379/0' - CELERY_IMPORTS = ('superset.sql_lab', ) - CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' - CELERY_ANNOTATIONS = {'tasks.add': {'rate_limit': '10/s'}} + class CeleryConfig(object): + BROKER_URL = 'redis://localhost:6379/0' + CELERY_IMPORTS = ('superset.sql_lab', ) + CELERY_RESULT_BACKEND = 'redis://localhost:6379/0' + CELERY_ANNOTATIONS = {'tasks.add': {'rate_limit': '10/s'}} - CELERY_CONFIG = CeleryConfig + CELERY_CONFIG = CeleryConfig To setup a result backend, you need to pass an instance of a derivative of ``werkzeug.contrib.cache.BaseCache`` to the ``RESULTS_BACKEND`` @@ -410,13 +410,13 @@ look something like: .. code-block:: python - # On S3 - from s3cache.s3cache import S3Cache - S3_CACHE_BUCKET = 'foobar-superset' - S3_CACHE_KEY_PREFIX = 'sql_lab_result' - RESULTS_BACKEND = S3Cache(S3_CACHE_BUCKET, S3_CACHE_KEY_PREFIX) + # On S3 + from s3cache.s3cache import S3Cache + S3_CACHE_BUCKET = 'foobar-superset' + S3_CACHE_KEY_PREFIX = 'sql_lab_result' + RESULTS_BACKEND = S3Cache(S3_CACHE_BUCKET, S3_CACHE_KEY_PREFIX) - # On Redis + # On Redis from werkzeug.contrib.cache import RedisCache RESULTS_BACKEND = RedisCache( host='localhost', port=6379, key_prefix='superset_results') From fca982c609571876a87ca3ccd8627dc4bc6c8726 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 26 Jul 2017 09:22:03 -0700 Subject: [PATCH 26/59] [bugfix] fix bar order (#3180) --- superset/assets/javascripts/modules/utils.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/superset/assets/javascripts/modules/utils.js b/superset/assets/javascripts/modules/utils.js index 7349dbb7f8f1..61949c7e2b39 100644 --- a/superset/assets/javascripts/modules/utils.js +++ b/superset/assets/javascripts/modules/utils.js @@ -229,14 +229,10 @@ export function initJQueryAjax() { } export function tryNumify(s) { - // Attempts casting to float, returns string when failing - try { - const parsed = parseFloat(s); - if (parsed) { - return parsed; - } - } catch (e) { - // pass + // Attempts casting to Number, returns string when failing + const n = Number(s); + if (isNaN(n)) { + return s; } - return s; + return n; } From cf1d0f38ad094d7809b500e2eeae39560111e626 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 26 Jul 2017 09:22:25 -0700 Subject: [PATCH 27/59] [bugfix] visualize flow error: 'Metric x is not valid' (#3181) The metric name in the frontend doesn't match the one generated on the backend. It turns out the explore view will default to the first metric so specifying one isn't needed. --- .../assets/javascripts/SqlLab/components/VisualizeModal.jsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/superset/assets/javascripts/SqlLab/components/VisualizeModal.jsx b/superset/assets/javascripts/SqlLab/components/VisualizeModal.jsx index 2ebfef9318a1..dce820cd677a 100644 --- a/superset/assets/javascripts/SqlLab/components/VisualizeModal.jsx +++ b/superset/assets/javascripts/SqlLab/components/VisualizeModal.jsx @@ -146,7 +146,6 @@ class VisualizeModal extends React.PureComponent { this.props.actions.createDatasource(this.buildVizOptions(), this) .done(() => { const columns = Object.keys(this.state.columns).map(k => this.state.columns[k]); - const mainMetric = columns.filter(d => d.agg)[0]; const mainGroupBy = columns.filter(d => d.is_dim)[0]; const formData = { datasource: this.props.datasource, @@ -154,10 +153,6 @@ class VisualizeModal extends React.PureComponent { since: '100 years ago', limit: '0', }; - if (mainMetric) { - formData.metrics = [mainMetric.name]; - formData.metric = mainMetric.name; - } if (mainGroupBy) { formData.groupby = [mainGroupBy.name]; } From aa95e03eb9305c81118876f05f351709daf4cb98 Mon Sep 17 00:00:00 2001 From: Fokko Driesprong Date: Wed, 26 Jul 2017 18:28:08 +0200 Subject: [PATCH 28/59] Fix the segment interval for pulling metadata (#3174) The end of the interval would be on the truncated today date, which means that you will exclude today. If your realtime ingestion job runs shorter than a day, the metadata cannot be pulled from the druid cluster. --- superset/connectors/druid/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index b4e1556a9eaa..69f10c75f596 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -492,7 +492,7 @@ def latest_metadata(self): lbound = datetime(1901, 1, 1).isoformat()[:10] rbound = datetime(2050, 1, 1).isoformat()[:10] if not self.version_higher(self.cluster.druid_version, '0.8.2'): - rbound = datetime.now().isoformat()[:10] + rbound = datetime.now().isoformat() try: segment_metadata = client.segment_metadata( datasource=self.datasource_name, From fb866a937bba48a94dc986789129f7f83cf2aa31 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 26 Jul 2017 23:11:11 +0200 Subject: [PATCH 29/59] Bump cryptography to 1.9 (#3065) As 1.7.2 doesn't compile here with openssl 1.1.0f --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 32cc77705219..6a3a18cf9783 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ def get_git_sha(): 'boto3==1.4.4', 'celery==3.1.25', 'colorama==0.3.9', - 'cryptography==1.7.2', + 'cryptography==1.9', 'flask-appbuilder==1.9.1', 'flask-cache==0.13.1', 'flask-migrate==2.0.3', From 25c599d0400cf320ce5accf50ed88e76ccff5980 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 27 Jul 2017 09:47:31 -0700 Subject: [PATCH 30/59] Escaping the user's SQL in the explore view (#3186) * Escaping the user's SQL in the explore view When executing SQL from SQL Lab, we use a lower level API to the database which doesn't require escaping the SQL. When going through the explore view, the stack chain leading to the same method may need escaping depending on how the DBAPI driver is written, and that is the case for Presto (and perhaps other drivers). * Using regex to avoid doubling doubles --- superset/connectors/sqla/models.py | 16 ++++++++++------ superset/db_engine_specs.py | 17 +++++++++-------- superset/sql_lab.py | 1 - 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 147c667df04b..0d06bfbde04c 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -285,10 +285,12 @@ def values_for_column(self, column_name, limit=10000): """ cols = {col.column_name: col for col in self.columns} target_col = cols[column_name] + tp = self.get_template_processor() + db_engine_spec = self.database.db_engine_spec qry = ( select([target_col.sqla_col]) - .select_from(self.get_from_clause()) + .select_from(self.get_from_clause(tp, db_engine_spec)) .distinct(column_name) ) if limit: @@ -322,7 +324,6 @@ def get_query_str(self, query_obj): ) logging.info(sql) sql = sqlparse.format(sql, reindent=True) - sql = self.database.db_engine_spec.sql_preprocessor(sql) return sql def get_sqla_table(self): @@ -331,12 +332,14 @@ def get_sqla_table(self): tbl.schema = self.schema return tbl - def get_from_clause(self, template_processor=None): + def get_from_clause(self, template_processor=None, db_engine_spec=None): # Supporting arbitrary SQL statements in place of tables if self.sql: from_sql = self.sql if template_processor: from_sql = template_processor.process_template(from_sql) + if db_engine_spec: + from_sql = db_engine_spec.escape_sql(from_sql) return TextAsFrom(sa.text(from_sql), []).alias('expr_qry') return self.get_sqla_table() @@ -367,13 +370,14 @@ def get_sqla_query( # sqla 'form_data': form_data, } template_processor = self.get_template_processor(**template_kwargs) + db_engine_spec = self.database.db_engine_spec # For backward compatibility if granularity not in self.dttm_cols: granularity = self.main_dttm_col # Database spec supports join-free timeslot grouping - time_groupby_inline = self.database.db_engine_spec.time_groupby_inline + time_groupby_inline = db_engine_spec.time_groupby_inline cols = {col.column_name: col for col in self.columns} metrics_dict = {m.metric_name: m for m in self.metrics} @@ -428,7 +432,7 @@ def get_sqla_query( # sqla groupby_exprs += [timestamp] # Use main dttm column to support index with secondary dttm columns - if self.database.db_engine_spec.time_secondary_columns and \ + if db_engine_spec.time_secondary_columns and \ self.main_dttm_col in self.dttm_cols and \ self.main_dttm_col != dttm_col.column_name: time_filters.append(cols[self.main_dttm_col]. @@ -438,7 +442,7 @@ def get_sqla_query( # sqla select_exprs += metrics_exprs qry = sa.select(select_exprs) - tbl = self.get_from_clause(template_processor) + tbl = self.get_from_clause(template_processor, db_engine_spec) if not columns: qry = qry.group_by(*groupby_exprs) diff --git a/superset/db_engine_specs.py b/superset/db_engine_specs.py index b460226c1aa6..d08f2a8feb79 100644 --- a/superset/db_engine_specs.py +++ b/superset/db_engine_specs.py @@ -73,6 +73,11 @@ def extra_table_metadata(cls, database, table_name, schema_name): """Returns engine-specific table metadata""" return {} + @classmethod + def escape_sql(cls, sql): + """Escapes the raw SQL""" + return sql + @classmethod def convert_dttm(cls, target_type, dttm): return "'{}'".format(dttm.strftime('%Y-%m-%d %H:%M:%S')) @@ -139,14 +144,6 @@ def adjust_database_uri(cls, uri, selected_schema): """ return uri - @classmethod - def sql_preprocessor(cls, sql): - """If the SQL needs to be altered prior to running it - - For example Presto needs to double `%` characters - """ - return sql - @classmethod def patch(cls): pass @@ -399,6 +396,10 @@ def adjust_database_uri(cls, uri, selected_schema=None): uri.database = database return uri + @classmethod + def escape_sql(cls, sql): + return re.sub(r'%%|%', "%%", sql) + @classmethod def convert_dttm(cls, target_type, dttm): tt = target_type.upper() diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 4b0bd863bcd0..638b29abbee1 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -154,7 +154,6 @@ def handle_error(msg): template_processor = get_template_processor( database=database, query=query) executed_sql = template_processor.process_template(executed_sql) - executed_sql = db_engine_spec.sql_preprocessor(executed_sql) except Exception as e: logging.exception(e) msg = "Template rendering failed: " + utils.error_msg_from_exception(e) From b888802e058ab667fe6572dda3ce37597d3812aa Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 27 Jul 2017 14:00:19 -0700 Subject: [PATCH 31/59] [sqllab] improve Hive support (#3187) * [sqllab] improve Hive support * Fix "Transport not open" bug * Getting progress bar to show * Bump pyhive to 0.4.0 * Getting [Track Job] button to show * Fix testzz --- setup.py | 2 +- .../SqlLab/components/ResultSet.jsx | 14 ++++ superset/config.py | 1 + superset/db_engine_specs.py | 78 +++++++++++++------ .../versions/ca69c70ec99b_tracking_url.py | 23 ++++++ superset/models/sql_lab.py | 2 + superset/sql_lab.py | 5 +- superset/views/core.py | 3 +- tests/db_engine_specs_test.py | 40 +++++----- tests/sqllab_tests.py | 5 +- 10 files changed, 122 insertions(+), 51 deletions(-) create mode 100644 superset/migrations/versions/ca69c70ec99b_tracking_url.py diff --git a/setup.py b/setup.py index 6a3a18cf9783..ad86b08959ae 100644 --- a/setup.py +++ b/setup.py @@ -61,7 +61,7 @@ def get_git_sha(): 'pandas==0.20.2', 'parsedatetime==2.0.0', 'pydruid==0.3.1', - 'PyHive>=0.3.0', + 'PyHive>=0.4.0', 'python-dateutil==2.6.0', 'requests==2.17.3', 'simplejson==3.10.0', diff --git a/superset/assets/javascripts/SqlLab/components/ResultSet.jsx b/superset/assets/javascripts/SqlLab/components/ResultSet.jsx index c9814ec214ad..f30605069664 100644 --- a/superset/assets/javascripts/SqlLab/components/ResultSet.jsx +++ b/superset/assets/javascripts/SqlLab/components/ResultSet.jsx @@ -155,6 +155,7 @@ export default class ResultSet extends React.PureComponent { } if (['running', 'pending', 'fetching'].indexOf(query.state) > -1) { let progressBar; + let trackingUrl; if (query.progress > 0 && query.state === 'running') { progressBar = ( ); } + if (query.trackingUrl) { + trackingUrl = ( + + ); + } return (
Loading... {progressBar} +
+ {trackingUrl} +
); } else if (query.state === 'failed') { diff --git a/superset/config.py b/superset/config.py index 6c38fa2f76b5..2c27415bf298 100644 --- a/superset/config.py +++ b/superset/config.py @@ -241,6 +241,7 @@ class CeleryConfig(object): CELERY_IMPORTS = ('superset.sql_lab', ) CELERY_RESULT_BACKEND = 'db+sqlite:///celery_results.sqlite' CELERY_ANNOTATIONS = {'tasks.add': {'rate_limit': '10/s'}} + CELERYD_LOG_LEVEL = 'DEBUG' CELERY_CONFIG = CeleryConfig """ CELERY_CONFIG = None diff --git a/superset/db_engine_specs.py b/superset/db_engine_specs.py index d08f2a8feb79..efe09a2d6973 100644 --- a/superset/db_engine_specs.py +++ b/superset/db_engine_specs.py @@ -637,6 +637,21 @@ class HiveEngineSpec(PrestoEngineSpec): engine = 'hive' cursor_execute_kwargs = {'async': True} + # Scoping regex at class level to avoid recompiling + # 17/02/07 19:36:38 INFO ql.Driver: Total jobs = 5 + jobs_stats_r = re.compile( + r'.*INFO.*Total jobs = (?P[0-9]+)') + # 17/02/07 19:37:08 INFO ql.Driver: Launching Job 2 out of 5 + launching_job_r = re.compile( + '.*INFO.*Launching Job (?P[0-9]+) out of ' + '(?P[0-9]+)') + # 17/02/07 19:36:58 INFO exec.Task: 2017-02-07 19:36:58,152 Stage-18 + # map = 0%, reduce = 0% + stage_progress_r = re.compile( + r'.*INFO.*Stage-(?P[0-9]+).*' + r'map = (?P[0-9]+)%.*' + r'reduce = (?P[0-9]+)%.*') + @classmethod def patch(cls): from pyhive import hive @@ -666,38 +681,27 @@ def adjust_database_uri(cls, uri, selected_schema=None): return uri @classmethod - def progress(cls, logs): - # 17/02/07 19:36:38 INFO ql.Driver: Total jobs = 5 - jobs_stats_r = re.compile( - r'.*INFO.*Total jobs = (?P[0-9]+)') - # 17/02/07 19:37:08 INFO ql.Driver: Launching Job 2 out of 5 - launching_job_r = re.compile( - '.*INFO.*Launching Job (?P[0-9]+) out of ' - '(?P[0-9]+)') - # 17/02/07 19:36:58 INFO exec.Task: 2017-02-07 19:36:58,152 Stage-18 - # map = 0%, reduce = 0% - stage_progress = re.compile( - r'.*INFO.*Stage-(?P[0-9]+).*' - r'map = (?P[0-9]+)%.*' - r'reduce = (?P[0-9]+)%.*') - total_jobs = None + def progress(cls, log_lines): + total_jobs = 1 # assuming there's at least 1 job current_job = None stages = {} - lines = logs.splitlines() - for line in lines: - match = jobs_stats_r.match(line) + for line in log_lines: + match = cls.jobs_stats_r.match(line) if match: - total_jobs = int(match.groupdict()['max_jobs']) - match = launching_job_r.match(line) + total_jobs = int(match.groupdict()['max_jobs']) or 1 + match = cls.launching_job_r.match(line) if match: current_job = int(match.groupdict()['job_number']) stages = {} - match = stage_progress.match(line) + match = cls.stage_progress_r.match(line) if match: stage_number = int(match.groupdict()['stage_number']) map_progress = int(match.groupdict()['map_progress']) reduce_progress = int(match.groupdict()['reduce_progress']) stages[stage_number] = (map_progress + reduce_progress) / 2 + logging.info( + "Progress detail: {}, " + "total jobs: {}".format(stages, total_jobs)) if not total_jobs or not current_job: return 0 @@ -709,6 +713,13 @@ def progress(cls, logs): ) return int(progress) + @classmethod + def get_tracking_url(cls, log_lines): + lkp = "Tracking URL = " + for line in log_lines: + if lkp in line: + return line.split(lkp)[1] + @classmethod def handle_cursor(cls, cursor, query, session): """Updates progress information""" @@ -718,18 +729,35 @@ def handle_cursor(cls, cursor, query, session): hive.ttypes.TOperationState.RUNNING_STATE, ) polled = cursor.poll() + last_log_line = 0 + tracking_url = None while polled.operationState in unfinished_states: query = session.query(type(query)).filter_by(id=query.id).one() if query.status == QueryStatus.STOPPED: cursor.cancel() break - logs = cursor.fetch_logs() - if logs: - progress = cls.progress(logs) + resp = cursor.fetch_logs() + if resp and resp.log: + log = resp.log or '' + log_lines = resp.log.splitlines() + logging.info("\n".join(log_lines[last_log_line:])) + last_log_line = len(log_lines) - 1 + progress = cls.progress(log_lines) + logging.info("Progress total: {}".format(progress)) + needs_commit = False if progress > query.progress: query.progress = progress - session.commit() + needs_commit = True + if not tracking_url: + tracking_url = cls.get_tracking_url(log_lines) + if tracking_url: + logging.info( + "Found the tracking url: {}".format(tracking_url)) + query.tracking_url = tracking_url + needs_commit = True + if needs_commit: + session.commit() time.sleep(5) polled = cursor.poll() diff --git a/superset/migrations/versions/ca69c70ec99b_tracking_url.py b/superset/migrations/versions/ca69c70ec99b_tracking_url.py new file mode 100644 index 000000000000..8a2ef38295c6 --- /dev/null +++ b/superset/migrations/versions/ca69c70ec99b_tracking_url.py @@ -0,0 +1,23 @@ +"""tracking_url + +Revision ID: ca69c70ec99b +Revises: a65458420354 +Create Date: 2017-07-26 20:09:52.606416 + +""" + +# revision identifiers, used by Alembic. +revision = 'ca69c70ec99b' +down_revision = 'a65458420354' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + + +def upgrade(): + op.add_column('query', sa.Column('tracking_url', sa.Text(), nullable=True)) + + +def downgrade(): + op.drop_column('query', 'tracking_url') diff --git a/superset/models/sql_lab.py b/superset/models/sql_lab.py index 00eb38815004..e2e125ad2438 100644 --- a/superset/models/sql_lab.py +++ b/superset/models/sql_lab.py @@ -69,6 +69,7 @@ class Query(Model): start_running_time = Column(Numeric(precision=20, scale=6)) end_time = Column(Numeric(precision=20, scale=6)) end_result_backend_time = Column(Numeric(precision=20, scale=6)) + tracking_url = Column(Text) changed_on = Column( DateTime, @@ -119,6 +120,7 @@ def to_dict(self): 'user': self.user.username, 'limit_reached': self.limit_reached, 'resultsKey': self.results_key, + 'trackingUrl': self.tracking_url, } @property diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 638b29abbee1..55130cdaed61 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -192,6 +192,9 @@ def handle_error(msg): conn.close() return handle_error(db_engine_spec.extract_error_message(e)) + logging.info("Fetching cursor description") + cursor_description = cursor.description + conn.commit() conn.close() @@ -203,7 +206,7 @@ def handle_error(msg): }, default=utils.json_iso_dttm_ser) column_names = ( - [col[0] for col in cursor.description] if cursor.description else []) + [col[0] for col in cursor_description] if cursor_description else []) column_names = dedup(column_names) cdf = dataframe.SupersetDataFrame(pd.DataFrame( list(data), columns=column_names)) diff --git a/superset/views/core.py b/superset/views/core.py index eded30904f4b..d5a31260c060 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -427,7 +427,7 @@ class SliceAddView(SliceModelView): # noqa class DashboardModelView(SupersetModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.Dashboard) - + list_title = _('List Dashboards') show_title = _('Show Dashboard') add_title = _('Add Dashboard') @@ -2030,6 +2030,7 @@ def sql_json(self): # Async request. if async: + logging.info("Running query on a Celery worker") # Ignore the celery future object and the request may time out. try: sql_lab.get_sql_results.delay( diff --git a/tests/db_engine_specs_test.py b/tests/db_engine_specs_test.py index 626a97bb3f9c..a3038132c0a8 100644 --- a/tests/db_engine_specs_test.py +++ b/tests/db_engine_specs_test.py @@ -5,7 +5,7 @@ import unittest -from superset import db_engine_specs +from superset.db_engine_specs import HiveEngineSpec class DbEngineSpecsTestCase(unittest.TestCase): @@ -13,36 +13,38 @@ def test_0_progress(self): log = """ 17/02/07 18:26:27 INFO log.PerfLogger: 17/02/07 18:26:27 INFO log.PerfLogger: - """ - self.assertEquals(0, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals( + 0, HiveEngineSpec.progress(log)) def test_0_progress(self): log = """ 17/02/07 18:26:27 INFO log.PerfLogger: 17/02/07 18:26:27 INFO log.PerfLogger: - """ - self.assertEquals(0, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals( + 0, HiveEngineSpec.progress(log)) def test_number_of_jobs_progress(self): log = """ 17/02/07 19:15:55 INFO ql.Driver: Total jobs = 2 - """ - self.assertEquals(0, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals(0, HiveEngineSpec.progress(log)) def test_job_1_launched_progress(self): log = """ 17/02/07 19:15:55 INFO ql.Driver: Total jobs = 2 17/02/07 19:15:55 INFO ql.Driver: Launching Job 1 out of 2 - """ - self.assertEquals(0, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals(0, HiveEngineSpec.progress(log)) def test_job_1_launched_stage_1_0_progress(self): log = """ 17/02/07 19:15:55 INFO ql.Driver: Total jobs = 2 17/02/07 19:15:55 INFO ql.Driver: Launching Job 1 out of 2 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 0%, reduce = 0% - """ - self.assertEquals(0, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals(0, HiveEngineSpec.progress(log)) def test_job_1_launched_stage_1_map_40_progress(self): log = """ @@ -50,8 +52,8 @@ def test_job_1_launched_stage_1_map_40_progress(self): 17/02/07 19:15:55 INFO ql.Driver: Launching Job 1 out of 2 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 0%, reduce = 0% 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 40%, reduce = 0% - """ - self.assertEquals(10, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals(10, HiveEngineSpec.progress(log)) def test_job_1_launched_stage_1_map_80_reduce_40_progress(self): log = """ @@ -60,8 +62,8 @@ def test_job_1_launched_stage_1_map_80_reduce_40_progress(self): 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 0%, reduce = 0% 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 40%, reduce = 0% 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 80%, reduce = 40% - """ - self.assertEquals(30, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals(30, HiveEngineSpec.progress(log)) def test_job_1_launched_stage_2_stages_progress(self): log = """ @@ -72,8 +74,8 @@ def test_job_1_launched_stage_2_stages_progress(self): 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 80%, reduce = 40% 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-2 map = 0%, reduce = 0% 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 100%, reduce = 0% - """ - self.assertEquals(12, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals(12, HiveEngineSpec.progress(log)) def test_job_2_launched_stage_2_stages_progress(self): log = """ @@ -83,5 +85,5 @@ def test_job_2_launched_stage_2_stages_progress(self): 17/02/07 19:15:55 INFO ql.Driver: Launching Job 2 out of 2 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 0%, reduce = 0% 17/02/07 19:16:09 INFO exec.Task: 2017-02-07 19:16:09,173 Stage-1 map = 40%, reduce = 0% - """ - self.assertEquals(60, db_engine_specs.HiveEngineSpec.progress(log)) + """.split('\n') + self.assertEquals(60, HiveEngineSpec.progress(log)) diff --git a/tests/sqllab_tests.py b/tests/sqllab_tests.py index 9e59adc7dd95..29d74f4dc350 100644 --- a/tests/sqllab_tests.py +++ b/tests/sqllab_tests.py @@ -189,12 +189,9 @@ def test_search_query_on_time(self): from_time = 'from={}'.format(int(first_query_time)) to_time = 'to={}'.format(int(second_query_time)) params = [from_time, to_time] - resp = self.get_resp('/superset/search_queries?'+'&'.join(params)) + resp = self.get_resp('/superset/search_queries?' + '&'.join(params)) data = json.loads(resp) self.assertEquals(2, len(data)) - for k in data: - self.assertLess(int(first_query_time), k['startDttm']) - self.assertLess(k['startDttm'], int(second_query_time)) def test_alias_duplicate(self): self.run_sql( From e584a9673f963a2dfd127114d8fe1a8e99fc731b Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 27 Jul 2017 14:01:13 -0700 Subject: [PATCH 32/59] Add BigQuery engine specifications (#3193) As contributed by @mxmzdlv on issue #945 --- superset/db_engine_specs.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/superset/db_engine_specs.py b/superset/db_engine_specs.py index efe09a2d6973..848145845ebc 100644 --- a/superset/db_engine_specs.py +++ b/superset/db_engine_specs.py @@ -939,6 +939,34 @@ def convert_dttm(cls, target_type, dttm): dttm.strftime('%Y-%m-%d %H:%M:%S')) return "'{}'".format(dttm.strftime('%Y-%m-%d %H:%M:%S')) + +class BQEngineSpec(BaseEngineSpec): + """Engine spec for Google's BigQuery + + As contributed by @mxmzdlv on issue #945""" + engine = 'bigquery' + + time_grains = ( + Grain("Time Column", _('Time Column'), "{col}"), + Grain("second", _('second'), "TIMESTAMP_TRUNC({col}, SECOND)"), + Grain("minute", _('minute'), "TIMESTAMP_TRUNC({col}, MINUTE)"), + Grain("hour", _('hour'), "TIMESTAMP_TRUNC({col}, HOUR)"), + Grain("day", _('day'), "TIMESTAMP_TRUNC({col}, DAY)"), + Grain("week", _('week'), "TIMESTAMP_TRUNC({col}, WEEK)"), + Grain("month", _('month'), "TIMESTAMP_TRUNC({col}, MONTH)"), + Grain("quarter", _('quarter'), "TIMESTAMP_TRUNC({col}, QUARTER)"), + Grain("year", _('year'), "TIMESTAMP_TRUNC({col}, YEAR)"), + ) + + @classmethod + def convert_dttm(cls, target_type, dttm): + tt = target_type.upper() + if tt == 'DATE': + return "'{}'".format(dttm.strftime('%Y-%m-%d')) + else: + return "'{}'".format(dttm.strftime('%Y-%m-%d %H:%M:%S')) + + engines = { o.engine: o for o in globals().values() if inspect.isclass(o) and issubclass(o, BaseEngineSpec)} From e4fba0ffb75f8210fc0a894d355851bcbfb0d285 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 27 Jul 2017 21:34:15 -0700 Subject: [PATCH 33/59] [bugfix] fix merge conflict that broke Hive support (#3196) --- superset/config.py | 6 +++++- superset/db_engine_specs.py | 35 +++++++++++++++++++++++------------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/superset/config.py b/superset/config.py index 2c27415bf298..47126f8bd4f4 100644 --- a/superset/config.py +++ b/superset/config.py @@ -308,8 +308,12 @@ class CeleryConfig(object): # configuration. These blueprints will get integrated in the app BLUEPRINTS = [] -try: +# Provide a callable that receives a tracking_url and returns another +# URL. This is used to translate internal Hadoop job tracker URL +# into a proxied one +TRACKING_URL_TRANSFORMER = lambda x: x +try: if CONFIG_PATH_ENV_VAR in os.environ: # Explicitly import config module that is not in pythonpath; useful # for case where app is being executed via pex. diff --git a/superset/db_engine_specs.py b/superset/db_engine_specs.py index 848145845ebc..b43e94e911f8 100644 --- a/superset/db_engine_specs.py +++ b/superset/db_engine_specs.py @@ -31,8 +31,9 @@ from superset.utils import SupersetTemplateException from superset.utils import QueryStatus -from superset import utils -from superset import cache_util +from superset import conf, cache_util, utils + +tracking_url_trans = conf.get('TRACKING_URL_TRANSFORMER') Grain = namedtuple('Grain', 'name label function') @@ -683,7 +684,7 @@ def adjust_database_uri(cls, uri, selected_schema=None): @classmethod def progress(cls, log_lines): total_jobs = 1 # assuming there's at least 1 job - current_job = None + current_job = 1 stages = {} for line in log_lines: match = cls.jobs_stats_r.match(line) @@ -692,6 +693,7 @@ def progress(cls, log_lines): match = cls.launching_job_r.match(line) if match: current_job = int(match.groupdict()['job_number']) + total_jobs = int(match.groupdict()['max_jobs']) or 1 stages = {} match = cls.stage_progress_r.match(line) if match: @@ -701,10 +703,9 @@ def progress(cls, log_lines): stages[stage_number] = (map_progress + reduce_progress) / 2 logging.info( "Progress detail: {}, " - "total jobs: {}".format(stages, total_jobs)) + "current job {}, " + "total jobs: {}".format(stages, current_job, total_jobs)) - if not total_jobs or not current_job: - return 0 stage_progress = sum( stages.values()) / len(stages.values()) if stages else 0 @@ -731,18 +732,16 @@ def handle_cursor(cls, cursor, query, session): polled = cursor.poll() last_log_line = 0 tracking_url = None + job_id = None while polled.operationState in unfinished_states: query = session.query(type(query)).filter_by(id=query.id).one() if query.status == QueryStatus.STOPPED: cursor.cancel() break - resp = cursor.fetch_logs() - if resp and resp.log: - log = resp.log or '' - log_lines = resp.log.splitlines() - logging.info("\n".join(log_lines[last_log_line:])) - last_log_line = len(log_lines) - 1 + log = cursor.fetch_logs() or '' + if log: + log_lines = log.splitlines() progress = cls.progress(log_lines) logging.info("Progress total: {}".format(progress)) needs_commit = False @@ -754,8 +753,20 @@ def handle_cursor(cls, cursor, query, session): if tracking_url: logging.info( "Found the tracking url: {}".format(tracking_url)) + tracking_url = tracking_url_trans(tracking_url) + logging.info( + "Transformation applied: {}".format(tracking_url)) query.tracking_url = tracking_url + job_id = tracking_url.split('/')[-2] + logging.info("Job id: {}".format(job_id)) needs_commit = True + if job_id and len(log_lines) > last_log_line: + # Wait for job id before logging things out + # this allows for prefixing all log lines and becoming + # searchable in something like Kibana + for l in log_lines[last_log_line:]: + logging.info("[{}] {}".format(job_id, l)) + last_log_line = len(log_lines) if needs_commit: session.commit() time.sleep(5) From ad5a4389a22ee83f946ed5ac8e748070886d204a Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Fri, 28 Jul 2017 11:42:01 -0700 Subject: [PATCH 34/59] Adding 'apache' to docs (#3194) --- docs/conf.py | 2 +- docs/index.rst | 32 ++++++++++++++++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index d1d506500621..fbc75e985458 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -51,7 +51,7 @@ master_doc = 'index' # General information about the project. -project = "Superset's documentation" +project = "Apache Superset" copyright = None author = u'Maxime Beauchemin' diff --git a/docs/index.rst b/docs/index.rst index 51410395219c..eba2e9451693 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,10 +1,10 @@ .. image:: _static/img/s.png -Superset's documentation -'''''''''''''''''''''''' +Apache Superset (incubating) +'''''''''''''''''''''''''''' -Superset is a data exploration platform designed to be visual, intuitive -and interactive. +Apache Superset (incubating) is a modern, enterprise-ready business +intelligence web application ---------------- @@ -12,25 +12,37 @@ and interactive. .. warning:: This project was originally named Panoramix, was renamed to Caravel in March 2016, and is currently named Superset as of November 2016 +.. important:: + + **Disclaimer**: Apache Superset is an effort undergoing incubation at The + Apache Software Foundation (ASF), sponsored by the Apache Incubator. + Incubation is required of all newly accepted projects until a further + review indicates that the infrastructure, communications, and + decision making process have stabilized in a manner consistent with + other successful ASF projects. While incubation status is not + necessarily a reflection of the completeness or stability of + the code, it does indicate that the project has yet to be fully + endorsed by the ASF. + Overview ======================================= Features --------- -- A rich set of data visualizations, integrated from some of the best - visualization libraries -- Create and share simple dashboards -- An extensible, high-granularity security/permission model allowing - intricate rules on who can access individual features and the dataset +- A rich set of data visualizations +- An easy-to-use interface for exploring and visualizing data +- Create and share dashboards - Enterprise-ready authentication with integration with major authentication providers (database, OpenID, LDAP, OAuth & REMOTE_USER through Flask AppBuilder) +- An extensible, high-granularity security/permission model allowing + intricate rules on who can access individual features and the dataset - A simple semantic layer, allowing users to control how data sources are displayed in the UI by defining which fields should show up in which drop-down and which aggregation and function metrics are made available to the user -- Integration with most RDBMS through SqlAlchemy +- Integration with most SQL-speaking RDBMS through SQLAlchemy - Deep integration with Druid.io ------ From 1e325d964524dc04e1e48aa86dbe463d1a10c81a Mon Sep 17 00:00:00 2001 From: Brian Wolfe Date: Fri, 28 Jul 2017 11:45:59 -0700 Subject: [PATCH 35/59] [druid] Allow custom druid postaggregators (#3146) * [druid] Allow custom druid postaggregators Also, fix the postaggregation for approxHistogram quantiles so it adds the dependent field and that can show up in the graphs/tables. In general, postAggregators add significant power, we should probably support including custom postAggregators. Plywood has standard postAggregators here, and a customAggregator escape hatch that allows you to define custom postAggregators. This commit adds a similar capability for Superset and a additional field/fields/fieldName breakdown of the typical naming for dependent aggregations, which should make it significantly easier to develop approxHistogram and custom postAggregation-required dashboards. * [druid] Minor style cleanup in tests file. * [druid] Apply code review suggestions * break out CustomPostAggregator into separate class. This just cleans up the creation of the postaggregator a little bit. * minor style issues. * move the function around so the git diff is more readable --- superset/connectors/druid/models.py | 129 +++++++++++++++++----------- tests/druid_tests.py | 77 ++++++++++++++++- 2 files changed, 151 insertions(+), 55 deletions(-) diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 69f10c75f596..cc85a92b7fae 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -50,6 +50,13 @@ def __init__(self, name, field_names, function): self.name = name +class CustomPostAggregator(Postaggregator): + """A way to allow users to specify completely custom PostAggregators""" + def __init__(self, name, post_aggregator): + self.name = name + self.post_aggregator = post_aggregator + + class DruidCluster(Model, AuditMixinNullable): """ORM object referencing the Druid clusters""" @@ -690,6 +697,75 @@ def granularity(period_name, timezone=None, origin=None): period_name).total_seconds() * 1000 return granularity + @staticmethod + def _metrics_and_post_aggs(metrics, metrics_dict): + all_metrics = [] + post_aggs = {} + + def recursive_get_fields(_conf): + _type = _conf.get('type') + _field = _conf.get('field') + _fields = _conf.get('fields') + + field_names = [] + if _type in ['fieldAccess', 'hyperUniqueCardinality', + 'quantile', 'quantiles']: + field_names.append(_conf.get('fieldName', '')) + + if _field: + field_names += recursive_get_fields(_field) + + if _fields: + for _f in _fields: + field_names += recursive_get_fields(_f) + + return list(set(field_names)) + + for metric_name in metrics: + metric = metrics_dict[metric_name] + if metric.metric_type != 'postagg': + all_metrics.append(metric_name) + else: + mconf = metric.json_obj + all_metrics += recursive_get_fields(mconf) + all_metrics += mconf.get('fieldNames', []) + if mconf.get('type') == 'javascript': + post_aggs[metric_name] = JavascriptPostAggregator( + name=mconf.get('name', ''), + field_names=mconf.get('fieldNames', []), + function=mconf.get('function', '')) + elif mconf.get('type') == 'quantile': + post_aggs[metric_name] = Quantile( + mconf.get('name', ''), + mconf.get('probability', ''), + ) + elif mconf.get('type') == 'quantiles': + post_aggs[metric_name] = Quantiles( + mconf.get('name', ''), + mconf.get('probabilities', ''), + ) + elif mconf.get('type') == 'fieldAccess': + post_aggs[metric_name] = Field(mconf.get('name')) + elif mconf.get('type') == 'constant': + post_aggs[metric_name] = Const( + mconf.get('value'), + output_name=mconf.get('name', '') + ) + elif mconf.get('type') == 'hyperUniqueCardinality': + post_aggs[metric_name] = HyperUniqueCardinality( + mconf.get('name') + ) + elif mconf.get('type') == 'arithmetic': + post_aggs[metric_name] = Postaggregator( + mconf.get('fn', "/"), + mconf.get('fields', []), + mconf.get('name', '')) + else: + post_aggs[metric_name] = CustomPostAggregator( + mconf.get('name', ''), + mconf) + return all_metrics, post_aggs + def values_for_column(self, column_name, limit=10000): @@ -749,61 +825,10 @@ def run_query( # noqa / druid query_str = "" metrics_dict = {m.metric_name: m for m in self.metrics} - all_metrics = [] - post_aggs = {} columns_dict = {c.column_name: c for c in self.columns} - def recursive_get_fields(_conf): - _fields = _conf.get('fields', []) - field_names = [] - for _f in _fields: - _type = _f.get('type') - if _type in ['fieldAccess', 'hyperUniqueCardinality']: - field_names.append(_f.get('fieldName')) - elif _type == 'arithmetic': - field_names += recursive_get_fields(_f) - return list(set(field_names)) - - for metric_name in metrics: - metric = metrics_dict[metric_name] - if metric.metric_type != 'postagg': - all_metrics.append(metric_name) - else: - mconf = metric.json_obj - all_metrics += recursive_get_fields(mconf) - all_metrics += mconf.get('fieldNames', []) - if mconf.get('type') == 'javascript': - post_aggs[metric_name] = JavascriptPostAggregator( - name=mconf.get('name', ''), - field_names=mconf.get('fieldNames', []), - function=mconf.get('function', '')) - elif mconf.get('type') == 'quantile': - post_aggs[metric_name] = Quantile( - mconf.get('name', ''), - mconf.get('probability', ''), - ) - elif mconf.get('type') == 'quantiles': - post_aggs[metric_name] = Quantiles( - mconf.get('name', ''), - mconf.get('probabilities', ''), - ) - elif mconf.get('type') == 'fieldAccess': - post_aggs[metric_name] = Field(mconf.get('name')) - elif mconf.get('type') == 'constant': - post_aggs[metric_name] = Const( - mconf.get('value'), - output_name=mconf.get('name', '') - ) - elif mconf.get('type') == 'hyperUniqueCardinality': - post_aggs[metric_name] = HyperUniqueCardinality( - mconf.get('name') - ) - else: - post_aggs[metric_name] = Postaggregator( - mconf.get('fn', "/"), - mconf.get('fields', []), - mconf.get('name', '')) + all_metrics, post_aggs = self._metrics_and_post_aggs(metrics, metrics_dict) aggregations = OrderedDict() for m in self.metrics: diff --git a/tests/druid_tests.py b/tests/druid_tests.py index d7b93dee0638..637afe984ce0 100644 --- a/tests/druid_tests.py +++ b/tests/druid_tests.py @@ -11,8 +11,8 @@ from mock import Mock, patch from superset import db, sm, security -from superset.connectors.druid.models import DruidCluster, DruidDatasource -from superset.connectors.druid.models import PyDruid +from superset.connectors.druid.models import DruidMetric, DruidCluster, DruidDatasource +from superset.connectors.druid.models import PyDruid, Quantile, Postaggregator from .base_tests import SupersetTestCase @@ -38,7 +38,7 @@ "metric1": { "type": "longSum", "name": "metric1", - "fieldName": "metric1"} + "fieldName": "metric1"}, }, "size": 300000, "numRows": 5000000 @@ -318,6 +318,77 @@ def test_sync_druid_perm(self, PyDruid): permission=permission, view_menu=view_menu).first() assert pv is not None + def test_metrics_and_post_aggs(self): + """ + Test generation of metrics and post-aggregations from an initial list + of superset metrics (which may include the results of either). This + primarily tests that specifying a post-aggregator metric will also + require the raw aggregation of the associated druid metric column. + """ + metrics_dict = { + 'unused_count': DruidMetric( + metric_name='unused_count', + verbose_name='COUNT(*)', + metric_type='count', + json=json.dumps({'type': 'count', 'name': 'unused_count'})), + 'some_sum': DruidMetric( + metric_name='some_sum', + verbose_name='SUM(*)', + metric_type='sum', + json=json.dumps({'type': 'sum', 'name': 'sum'})), + 'a_histogram': DruidMetric( + metric_name='a_histogram', + verbose_name='APPROXIMATE_HISTOGRAM(*)', + metric_type='approxHistogramFold', + json=json.dumps({'type': 'approxHistogramFold', 'name': 'a_histogram'})), + 'aCustomMetric': DruidMetric( + metric_name='aCustomMetric', + verbose_name='MY_AWESOME_METRIC(*)', + metric_type='aCustomType', + json=json.dumps({'type': 'customMetric', 'name': 'aCustomMetric'})), + 'quantile_p95': DruidMetric( + metric_name='quantile_p95', + verbose_name='P95(*)', + metric_type='postagg', + json=json.dumps({ + 'type': 'quantile', + 'probability': 0.95, + 'name': 'p95', + 'fieldName': 'a_histogram'})), + 'aCustomPostAgg': DruidMetric( + metric_name='aCustomPostAgg', + verbose_name='CUSTOM_POST_AGG(*)', + metric_type='postagg', + json=json.dumps({ + 'type': 'customPostAgg', + 'name': 'aCustomPostAgg', + 'field': { + 'type': 'fieldAccess', + 'fieldName': 'aCustomMetric'}})), + } + + metrics = ['some_sum'] + all_metrics, post_aggs = DruidDatasource._metrics_and_post_aggs( + metrics, metrics_dict) + + assert all_metrics == ['some_sum'] + assert post_aggs == {} + + metrics = ['quantile_p95'] + all_metrics, post_aggs = DruidDatasource._metrics_and_post_aggs( + metrics, metrics_dict) + + result_postaggs = set(['quantile_p95']) + assert all_metrics == ['a_histogram'] + assert set(post_aggs.keys()) == result_postaggs + + metrics = ['aCustomPostAgg'] + all_metrics, post_aggs = DruidDatasource._metrics_and_post_aggs( + metrics, metrics_dict) + + result_postaggs = set(['aCustomPostAgg']) + assert all_metrics == ['aCustomMetric'] + assert set(post_aggs.keys()) == result_postaggs if __name__ == '__main__': From b58cfbcb9124d6c43dfd9b748f777b07b101aa5f Mon Sep 17 00:00:00 2001 From: Rogan Date: Sat, 29 Jul 2017 05:16:38 +0800 Subject: [PATCH 36/59] add combine config for metrics in pivot table (#3086) * add combine config for metrics in pivot table * change method to stack/unstack * update backendSync --- superset/assets/backendSync.json | 6 ++++++ superset/assets/javascripts/explore/stores/controls.jsx | 8 ++++++++ superset/assets/javascripts/explore/stores/visTypes.js | 2 +- superset/viz.py | 3 +++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/superset/assets/backendSync.json b/superset/assets/backendSync.json index 1d8ba55fa746..71e713032849 100644 --- a/superset/assets/backendSync.json +++ b/superset/assets/backendSync.json @@ -750,6 +750,12 @@ "default": false, "description": "Sort bars by x labels." }, + "combine_metric": { + "type": "CheckboxControl", + "label": "Combine Metrics", + "default": false, + "description": "Display metrics side by side within each column, as opposed to each column being displayed side by side for each metric." + }, "show_controls": { "type": "CheckboxControl", "label": "Extra Controls", diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 03d1fbddd59c..3d4151bcfaef 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -249,6 +249,14 @@ export const controls = { description: 'Sort bars by x labels.', }, + combine_metric: { + type: 'CheckboxControl', + label: 'Combine Metrics', + default: false, + description: 'Display metrics side by side within each column, as ' + + 'opposed to each column being displayed side by side for each metric.', + }, + show_controls: { type: 'CheckboxControl', label: 'Extra Controls', diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 7291286a1f74..4dd2700c5ea9 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -337,7 +337,7 @@ export const visTypes = { controlSetRows: [ ['groupby', 'columns'], ['metrics', 'pandas_aggfunc'], - ['number_format', 'pivot_margins'], + ['number_format', 'combine_metric', 'pivot_margins'], ], }, ], diff --git a/superset/viz.py b/superset/viz.py index 19ff165e26a1..de1f635ec533 100755 --- a/superset/viz.py +++ b/superset/viz.py @@ -402,6 +402,9 @@ def get_data(self, df): aggfunc=self.form_data.get('pandas_aggfunc'), margins=self.form_data.get('pivot_margins'), ) + # Display metrics side by side with each column + if self.form_data.get('combine_metric'): + df = df.stack(0).unstack() return dict( columns=list(df.columns), html=df.to_html( From 58a704b84c152485b7f9c44c950c70046ca4e99e Mon Sep 17 00:00:00 2001 From: Andrew Pariser Date: Fri, 28 Jul 2017 14:57:24 -0700 Subject: [PATCH 37/59] Autofocus search input in VizTypeControl modal onEnter (#2929) --- .../components/controls/VizTypeControl.jsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx b/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx index 8fdd3f2d5b12..0b454ac86245 100644 --- a/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx +++ b/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx @@ -25,17 +25,27 @@ export default class VizTypeControl extends React.PureComponent { }; this.toggleModal = this.toggleModal.bind(this); this.changeSearch = this.changeSearch.bind(this); + this.setSearchRef = this.setSearchRef.bind(this); + this.focusSearch = this.focusSearch.bind(this); } onChange(vizType) { this.props.onChange(vizType); this.setState({ showModal: false }); } + setSearchRef(searchRef) { + this.searchRef = searchRef; + } toggleModal() { this.setState({ showModal: !this.state.showModal }); } changeSearch(event) { this.setState({ filter: event.target.value }); } + focusSearch() { + if (this.searchRef) { + this.searchRef.focus(); + } + } renderVizType(vizType) { const vt = vizType; return ( @@ -82,7 +92,13 @@ export default class VizTypeControl extends React.PureComponent { - + Select a visualization type @@ -90,6 +106,7 @@ export default class VizTypeControl extends React.PureComponent {
{ this.setSearchRef(ref); }} type="text" bsSize="sm" value={this.state.filter} From 219f33f0d1fcd05db1807f5b95caea8b0edd92f7 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Fri, 28 Jul 2017 17:34:09 -0700 Subject: [PATCH 38/59] Speed up JS build time (#3203) Also bumping a few related libs --- superset/assets/package.json | 15 +++++++-------- superset/assets/webpack.config.js | 8 ++++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/superset/assets/package.json b/superset/assets/package.json index 055d9fd0fd1b..3a37efe8c143 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -18,7 +18,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/airbnb/superset.git" + "url": "git+https://github.com/apache/incubator-superset.git" }, "keywords": [ "big", @@ -32,11 +32,11 @@ "database", "flask" ], - "author": "Airbnb", + "author": "Apache", "bugs": { - "url": "https://github.com/airbnb/superset/issues" + "url": "https://github.com/apache/incubator-superset/issues" }, - "homepage": "https://github.com/airbnb/superset#readme", + "homepage": "http://superset.apache.org/", "dependencies": { "@data-ui/event-flow": "0.0.4", "babel-register": "^6.24.1", @@ -55,7 +55,6 @@ "datatables.net-bs": "^1.10.12", "immutable": "^3.8.1", "jquery": "^3.2.1", - "jsdom": "9.12.0", "lodash.throttle": "^4.1.1", "moment": "^2.14.1", "mustache": "^2.2.1", @@ -127,8 +126,8 @@ "transform-loader": "^0.2.3", "uglifyjs-webpack-plugin": "^0.4.6", "url-loader": "^0.5.7", - "webpack": "^2.3.3", - "webpack-manifest-plugin": "1.1.0", - "webworkify-webpack": "2.0.4" + "webpack": "^3.4.1", + "webpack-manifest-plugin": "1.2.1", + "webworkify-webpack": "2.0.5" } } diff --git a/superset/assets/webpack.config.js b/superset/assets/webpack.config.js index 6b80952c9093..e3413e5d14c2 100644 --- a/superset/assets/webpack.config.js +++ b/superset/assets/webpack.config.js @@ -130,11 +130,11 @@ if (process.env.NODE_ENV === 'production') { const UJSplugin = new webpack.optimize.UglifyJsPlugin({ sourceMap: false, minimize: true, - compress: { - drop_debugger: true, - warnings: false, - drop_console: true, + parallel: { + cache: true, + workers: 4, }, + compress: false, }); config.plugins.push(UJSplugin); } From 299e9ce6b8a4b4de1735261299024bfbeafcc705 Mon Sep 17 00:00:00 2001 From: Xingze Zhang Date: Tue, 1 Aug 2017 03:24:19 +0800 Subject: [PATCH 39/59] fix issue 3204 (#3205) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd45739c031e..6c706f1e7e2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,7 +70,7 @@ meets these guidelines: ## Documentation -The latest documentation and tutorial are available [here](http://airbnb.io/superset). +The latest documentation and tutorial are available [here](https://superset.incubator.apache.org/). Contributing to the official documentation is relatively easy, once you've setup your environment and done an edit end-to-end. The docs can be found in the From 774ad45efbaad86238004e9a21d4c733f57bde36 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Mon, 31 Jul 2017 22:22:08 -0700 Subject: [PATCH 40/59] [bugfix] capture Hive job_id pre-url transformation (#3213) --- superset/db_engine_specs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/db_engine_specs.py b/superset/db_engine_specs.py index b43e94e911f8..f159c7cd6735 100644 --- a/superset/db_engine_specs.py +++ b/superset/db_engine_specs.py @@ -751,13 +751,13 @@ def handle_cursor(cls, cursor, query, session): if not tracking_url: tracking_url = cls.get_tracking_url(log_lines) if tracking_url: + job_id = tracking_url.split('/')[-2] logging.info( "Found the tracking url: {}".format(tracking_url)) tracking_url = tracking_url_trans(tracking_url) logging.info( "Transformation applied: {}".format(tracking_url)) query.tracking_url = tracking_url - job_id = tracking_url.split('/')[-2] logging.info("Job id: {}".format(job_id)) needs_commit = True if job_id and len(log_lines) > last_log_line: From 9c1ca07c4026947e9a435d860a65f144f4abfb41 Mon Sep 17 00:00:00 2001 From: Xingze Zhang Date: Tue, 1 Aug 2017 23:54:35 +0800 Subject: [PATCH 41/59] [docs] update url in CONTRIBUTING.md (#3212) --- CONTRIBUTING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c706f1e7e2b..5365adb25e0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -144,7 +144,7 @@ referenced in the rst, e.g. aren't actually included in that directory. _Instead_, you'll want to add and commit images (and any other static assets) to the _superset/assets/images_ directory. -When the docs are being pushed to [airbnb.io](http://airbnb.io/superset/), images +When the docs are being pushed to [Apache Superset (incubating)](https://superset.incubator.apache.org/), images will be moved from there to the _\_static/img_ directory, just like they're referenced in the docs. @@ -161,12 +161,12 @@ instead. ## Setting up a Python development environment -Check the [OS dependencies](http://airbnb.io/superset/installation.html#os-dependencies) before follows these steps. +Check the [OS dependencies](https://superset.incubator.apache.org/installation.html#os-dependencies) before follows these steps. # fork the repo on GitHub and then clone it # alternatively you may want to clone the main repo but that won't work # so well if you are planning on sending PRs - # git clone git@github.com:airbnb/superset.git + # git clone git@github.com:apache/incubator-superset.git # [optional] setup a virtual env and activate it virtualenv env @@ -379,4 +379,4 @@ to take effect, they need to be compiled using this command: Here's an example as a Github PR with comments that describe what the different sections of the code do: -https://github.com/airbnb/superset/pull/3013 +https://github.com/apache/incubator-superset/pull/3013 From 48760849ec92c20ef60beff2cfd074bf85ac723a Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 1 Aug 2017 10:25:13 -0700 Subject: [PATCH 42/59] [sqllab/cosmetics] add margin-top for labels in query history (#3222) --- superset/assets/javascripts/SqlLab/main.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/assets/javascripts/SqlLab/main.css b/superset/assets/javascripts/SqlLab/main.css index a3ad7dbe6b44..ad2bb37c0e3e 100644 --- a/superset/assets/javascripts/SqlLab/main.css +++ b/superset/assets/javascripts/SqlLab/main.css @@ -265,7 +265,7 @@ div.tablePopover:hover { } .QueryTable .label { - margin-top: 5px; + display: inline-block; } .ResultsModal .modal-body { From 3b129253a322ea8749a296bc7a3b3c75b93d060c Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 1 Aug 2017 10:25:52 -0700 Subject: [PATCH 43/59] [explore] nvd3 sort values in rich tooltip (#3197) --- superset/assets/stylesheets/superset.css | 2 +- superset/assets/visualizations/nvd3_vis.js | 31 +++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/superset/assets/stylesheets/superset.css b/superset/assets/stylesheets/superset.css index 2cbe27fa9d91..ef12c7eb36fd 100644 --- a/superset/assets/stylesheets/superset.css +++ b/superset/assets/stylesheets/superset.css @@ -13,7 +13,7 @@ body { } .emph { - font-weight: bold; + font-weight: bold !important; } .alert.alert-danger > .debugger { diff --git a/superset/assets/visualizations/nvd3_vis.js b/superset/assets/visualizations/nvd3_vis.js index 1f34d9b797a7..21342942cfd8 100644 --- a/superset/assets/visualizations/nvd3_vis.js +++ b/superset/assets/visualizations/nvd3_vis.js @@ -298,9 +298,6 @@ function nvd3Vis(slice, payload) { chart.height(height); slice.container.css('height', height + 'px'); - if ((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) { - chart.useInteractiveGuideline(true); - } if (chart.forceY && fd.y_axis_bounds && (fd.y_axis_bounds[0] !== null || fd.y_axis_bounds[1] !== null)) { @@ -342,6 +339,34 @@ function nvd3Vis(slice, payload) { if (vizType !== 'bullet') { chart.color(d => category21(d[colorKey])); } + if ((vizType === 'line' || vizType === 'area') && fd.rich_tooltip) { + chart.useInteractiveGuideline(true); + if (vizType === 'line') { + // Custom sorted tooltip + chart.interactiveLayer.tooltip.contentGenerator((d) => { + let tooltip = ''; + tooltip += "'; + d.series.sort((a, b) => a.value >= b.value ? -1 : 1); + d.series.forEach((series) => { + tooltip += ( + `` + + `' + + `` + + `` + + '' + ); + }); + tooltip += '
" + + `${xAxisFormatter(d.value)}` + + '
` + + '
' + + '
${series.key}${yAxisFormatter(series.value)}
'; + return tooltip; + }); + } + } if (fd.x_axis_label && fd.x_axis_label !== '' && chart.xAxis) { let distance = 0; From 48821b5101a31f64acee391fc24af4e0809f9b26 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 1 Aug 2017 10:26:13 -0700 Subject: [PATCH 44/59] [sqllab] fix UI shows 'The query returned no results' momentarily (#3214) this is visible when running async queries between the fetching and success state as the rows are getting cached in the component --- .../SqlLab/components/ResultSet.jsx | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/superset/assets/javascripts/SqlLab/components/ResultSet.jsx b/superset/assets/javascripts/SqlLab/components/ResultSet.jsx index f30605069664..79c6d9c8c435 100644 --- a/superset/assets/javascripts/SqlLab/components/ResultSet.jsx +++ b/superset/assets/javascripts/SqlLab/components/ResultSet.jsx @@ -35,7 +35,7 @@ export default class ResultSet extends React.PureComponent { this.state = { searchText: '', showModal: false, - data: [], + data: null, height: props.search ? props.height - RESULT_SET_CONTROLS_HEIGHT : props.height, }; } @@ -146,44 +146,12 @@ export default class ResultSet extends React.PureComponent { const query = this.props.query; let sql; - if (query.state === 'stopped') { - return Query was stopped; - } - if (this.props.showSql) { sql = ; } - if (['running', 'pending', 'fetching'].indexOf(query.state) > -1) { - let progressBar; - let trackingUrl; - if (query.progress > 0 && query.state === 'running') { - progressBar = ( - ); - } - if (query.trackingUrl) { - trackingUrl = ( - - ); - } - return ( -
- Loading... - - {progressBar} -
- {trackingUrl} -
-
- ); + + if (query.state === 'stopped') { + return Query was stopped; } else if (query.state === 'failed') { return {query.errorMessage}; } else if (query.state === 'success' && query.ctas) { @@ -206,10 +174,10 @@ export default class ResultSet extends React.PureComponent { let data; if (this.props.cache && query.cached) { data = this.state.data; - } else { - data = results ? results.data : []; + } else if (results && results.data) { + data = results.data; } - if (results && data.length > 0) { + if (data && data.length > 0) { return (
); + } else if (data && data.length === 0) { + return The query returned no data; } } if (query.cached) { @@ -240,7 +210,36 @@ export default class ResultSet extends React.PureComponent { ); } - return The query returned no data; + let progressBar; + let trackingUrl; + if (query.progress > 0 && query.state === 'running') { + progressBar = ( + ); + } + if (query.trackingUrl) { + trackingUrl = ( + + ); + } + return ( +
+ Loading... + + {progressBar} +
+ {trackingUrl} +
+
+ ); } } ResultSet.propTypes = propTypes; From 62fcdf2a92cc2fa93f77adbd650fc0a7031850a2 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 1 Aug 2017 12:08:00 -0700 Subject: [PATCH 45/59] [explore] DatasourceControl to pick datasource in modal (#3210) * [explore] DatasourceControl to pick datasource in modal Makes it easier to change datasource, also makes it such that the list of all datasources doesn't need to be loaded upfront. * Adding more metadata --- superset/assets/javascripts/SqlLab/index.jsx | 2 +- .../components/InfoTooltipWithTrigger.jsx | 9 +- .../explore/actions/exploreActions.js | 38 ----- .../explore/components/Control.jsx | 6 +- .../components/ExploreViewContainer.jsx | 3 - .../components/controls/DatasourceControl.jsx | 157 ++++++++++++++++++ .../components/controls/VizTypeControl.jsx | 20 ++- superset/assets/javascripts/explore/index.jsx | 1 + .../explore/reducers/exploreReducer.js | 19 --- .../javascripts/explore/stores/controls.jsx | 20 +-- .../components/DatasourceControl_spec.jsx | 32 ++++ .../explore/exploreActions_spec.js | 38 ----- .../reactable-pagination.css | 0 superset/assets/stylesheets/superset.css | 3 + superset/connectors/base/models.py | 24 +++ superset/connectors/druid/models.py | 4 + superset/connectors/sqla/models.py | 4 + superset/views/core.py | 3 +- 18 files changed, 257 insertions(+), 126 deletions(-) create mode 100644 superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx create mode 100644 superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx rename superset/assets/{javascripts/SqlLab => stylesheets}/reactable-pagination.css (100%) diff --git a/superset/assets/javascripts/SqlLab/index.jsx b/superset/assets/javascripts/SqlLab/index.jsx index e292c2576c1a..ba0992472017 100644 --- a/superset/assets/javascripts/SqlLab/index.jsx +++ b/superset/assets/javascripts/SqlLab/index.jsx @@ -11,7 +11,7 @@ import App from './components/App'; import { appSetup } from '../common'; import './main.css'; -import './reactable-pagination.css'; +import '../../stylesheets/reactable-pagination.css'; import '../components/FilterableTable/FilterableTableStyles.css'; appSetup(); diff --git a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx index 07b4db473e3a..85bc7fb50d1a 100644 --- a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx +++ b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx @@ -8,18 +8,23 @@ const propTypes = { tooltip: PropTypes.string.isRequired, icon: PropTypes.string, className: PropTypes.string, + onClick: PropTypes.func, }; const defaultProps = { icon: 'question-circle-o', }; -export default function InfoTooltipWithTrigger({ label, tooltip, icon, className }) { +export default function InfoTooltipWithTrigger({ label, tooltip, icon, className, onClick }) { return ( {tooltip}} > - + ); } diff --git a/superset/assets/javascripts/explore/actions/exploreActions.js b/superset/assets/javascripts/explore/actions/exploreActions.js index 6d8ed83488ba..d45acd5834b9 100644 --- a/superset/assets/javascripts/explore/actions/exploreActions.js +++ b/superset/assets/javascripts/explore/actions/exploreActions.js @@ -16,11 +16,6 @@ export function setDatasource(datasource) { return { type: SET_DATASOURCE, datasource }; } -export const SET_DATASOURCES = 'SET_DATASOURCES'; -export function setDatasources(datasources) { - return { type: SET_DATASOURCES, datasources }; -} - export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED'; export function fetchDatasourceStarted() { return { type: FETCH_DATASOURCE_STARTED }; @@ -36,21 +31,6 @@ export function fetchDatasourceFailed(error) { return { type: FETCH_DATASOURCE_FAILED, error }; } -export const FETCH_DATASOURCES_STARTED = 'FETCH_DATASOURCES_STARTED'; -export function fetchDatasourcesStarted() { - return { type: FETCH_DATASOURCES_STARTED }; -} - -export const FETCH_DATASOURCES_SUCCEEDED = 'FETCH_DATASOURCES_SUCCEEDED'; -export function fetchDatasourcesSucceeded() { - return { type: FETCH_DATASOURCES_SUCCEEDED }; -} - -export const FETCH_DATASOURCES_FAILED = 'FETCH_DATASOURCES_FAILED'; -export function fetchDatasourcesFailed(error) { - return { type: FETCH_DATASOURCES_FAILED, error }; -} - export const RESET_FIELDS = 'RESET_FIELDS'; export function resetControls() { return { type: RESET_FIELDS }; @@ -83,24 +63,6 @@ export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) }; } -export function fetchDatasources() { - return function (dispatch) { - dispatch(fetchDatasourcesStarted()); - const url = '/superset/datasources/'; - $.ajax({ - type: 'GET', - url, - success: (data) => { - dispatch(setDatasources(data)); - dispatch(fetchDatasourcesSucceeded()); - }, - error(error) { - dispatch(fetchDatasourcesFailed(error.responseJSON.error)); - }, - }); - }; -} - export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR'; export function toggleFaveStar(isStarred) { return { type: TOGGLE_FAVE_STAR, isStarred }; diff --git a/superset/assets/javascripts/explore/components/Control.jsx b/superset/assets/javascripts/explore/components/Control.jsx index d9aaea72da33..b0dce3549fd9 100644 --- a/superset/assets/javascripts/explore/components/Control.jsx +++ b/superset/assets/javascripts/explore/components/Control.jsx @@ -1,24 +1,26 @@ import React from 'react'; import PropTypes from 'prop-types'; +import BoundsControl from './controls/BoundsControl'; import CheckboxControl from './controls/CheckboxControl'; +import DatasourceControl from './controls/DatasourceControl'; import FilterControl from './controls/FilterControl'; import HiddenControl from './controls/HiddenControl'; import SelectControl from './controls/SelectControl'; import TextAreaControl from './controls/TextAreaControl'; import TextControl from './controls/TextControl'; import VizTypeControl from './controls/VizTypeControl'; -import BoundsControl from './controls/BoundsControl'; const controlMap = { + BoundsControl, CheckboxControl, + DatasourceControl, FilterControl, HiddenControl, SelectControl, TextAreaControl, TextControl, VizTypeControl, - BoundsControl, }; const controlTypes = Object.keys(controlMap); diff --git a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx index f015aa99b6a0..e8c904263bce 100644 --- a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx +++ b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx @@ -33,9 +33,6 @@ class ExploreViewContainer extends React.Component { } componentDidMount() { - if (!this.props.standalone) { - this.props.actions.fetchDatasources(); - } window.addEventListener('resize', this.handleResize.bind(this)); this.triggerQueryIfNeeded(); } diff --git a/superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx b/superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx new file mode 100644 index 000000000000..20e12a5f9e5d --- /dev/null +++ b/superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx @@ -0,0 +1,157 @@ +/* global notify */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from 'reactable'; +import { Label, FormControl, Modal, OverlayTrigger, Tooltip } from 'react-bootstrap'; + +import ControlHeader from '../ControlHeader'; +import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger'; + +const propTypes = { + description: PropTypes.string, + label: PropTypes.string, + name: PropTypes.string.isRequired, + onChange: PropTypes.func, + value: PropTypes.string.isRequired, + datasource: PropTypes.object.isRequired, +}; + +const defaultProps = { + onChange: () => {}, +}; + +export default class DatasourceControl extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showModal: false, + filter: '', + loading: true, + }; + this.toggleModal = this.toggleModal.bind(this); + this.changeSearch = this.changeSearch.bind(this); + this.setSearchRef = this.setSearchRef.bind(this); + this.onEnterModal = this.onEnterModal.bind(this); + } + onChange(vizType) { + this.props.onChange(vizType); + this.setState({ showModal: false }); + } + onEnterModal() { + if (this.searchRef) { + this.searchRef.focus(); + } + const url = '/superset/datasources/'; + const that = this; + if (!this.state.datasources) { + $.ajax({ + type: 'GET', + url, + success: (data) => { + const datasources = data.map(ds => ({ + rawName: ds.name, + connection: ds.connection, + schema: ds.schema, + name: ( + + {ds.name} + ), + type: ds.type, + })); + + that.setState({ loading: false, datasources }); + }, + error() { + that.setState({ loading: false }); + notify.error('Something went wrong while fetching the datasource list'); + }, + }); + } + } + setSearchRef(searchRef) { + this.searchRef = searchRef; + } + toggleModal() { + this.setState({ showModal: !this.state.showModal }); + } + changeSearch(event) { + this.setState({ filter: event.target.value }); + } + selectDatasource(datasourceId) { + this.setState({ showModal: false }); + this.props.onChange(datasourceId); + } + render() { + return ( +
+ + Click to point to another datasource + } + > + + + { + window.location = this.props.datasource.edit_url; + }} + /> + + + Select a datasource + + +
+ { this.setSearchRef(ref); }} + type="text" + bsSize="sm" + value={this.state.filter} + placeholder="Search / Filter" + onChange={this.changeSearch} + /> +
+ {this.state.loading && + Loading... + } + {this.state.datasources && + + } + + + ); + } +} + +DatasourceControl.propTypes = propTypes; +DatasourceControl.defaultProps = defaultProps; diff --git a/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx b/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx index 0b454ac86245..0fc82660f2b4 100644 --- a/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx +++ b/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Label, Row, Col, FormControl, Modal } from 'react-bootstrap'; +import { + Label, Row, Col, FormControl, Modal, OverlayTrigger, + Tooltip } from 'react-bootstrap'; import visTypes from '../../stores/visTypes'; import ControlHeader from '../ControlHeader'; @@ -85,13 +87,17 @@ export default class VizTypeControl extends React.PureComponent {
edit - } /> - + Click to change visualization type + } + > + + { - const datasources = state.datasources || []; - return { - choices: datasources, - isLoading: datasources.length === 0, - rightNode: state.datasource ? - edit - : null, - }; - }, - description: '', + description: null, + mapStateToProps: state => ({ + datasource: state.datasource, + }), }, viz_type: { diff --git a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx new file mode 100644 index 000000000000..c46ded004a23 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import { Modal } from 'react-bootstrap'; +import DatasourceControl from '../../../../javascripts/explore/components/controls/DatasourceControl'; + +const defaultProps = { + name: 'datasource', + label: 'Datasource', + value: '1__table', + datasource: { + name: 'birth_names', + type: 'table', + uid: '1__table', + id: 1, + }, + onChange: sinon.spy(), +}; + +describe('DatasourceControl', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders a Modal', () => { + expect(wrapper.find(Modal)).to.have.lengthOf(1); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/exploreActions_spec.js b/superset/assets/spec/javascripts/explore/exploreActions_spec.js index 86173be4ecdf..9fa02e4b1248 100644 --- a/superset/assets/spec/javascripts/explore/exploreActions_spec.js +++ b/superset/assets/spec/javascripts/explore/exploreActions_spec.js @@ -82,44 +82,6 @@ describe('fetching actions', () => { }); }); - describe('fetchDatasources', () => { - const makeRequest = () => { - request = actions.fetchDatasources(); - request(dispatch); - }; - - it('calls fetchDatasourcesStarted', () => { - makeRequest(); - expect(dispatch.args[0][0].type).to.equal(actions.FETCH_DATASOURCES_STARTED); - }); - - it('makes the ajax request', () => { - makeRequest(); - expect(ajaxStub.calledOnce).to.be.true; - }); - - it('calls correct url', () => { - const url = '/superset/datasources/'; - makeRequest(); - expect(ajaxStub.getCall(0).args[0].url).to.equal(url); - }); - - it('calls correct actions on error', () => { - ajaxStub.yieldsTo('error', { responseJSON: { error: 'error text' } }); - makeRequest(); - expect(dispatch.callCount).to.equal(2); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.FETCH_DATASOURCES_FAILED); - }); - - it('calls correct actions on success', () => { - ajaxStub.yieldsTo('success', { data: '' }); - makeRequest(); - expect(dispatch.callCount).to.equal(3); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.SET_DATASOURCES); - expect(dispatch.getCall(2).args[0].type).to.equal(actions.FETCH_DATASOURCES_SUCCEEDED); - }); - }); - describe('fetchDashboards', () => { const userID = 1; const mockDashboardData = { diff --git a/superset/assets/javascripts/SqlLab/reactable-pagination.css b/superset/assets/stylesheets/reactable-pagination.css similarity index 100% rename from superset/assets/javascripts/SqlLab/reactable-pagination.css rename to superset/assets/stylesheets/reactable-pagination.css diff --git a/superset/assets/stylesheets/superset.css b/superset/assets/stylesheets/superset.css index ef12c7eb36fd..20041330c943 100644 --- a/superset/assets/stylesheets/superset.css +++ b/superset/assets/stylesheets/superset.css @@ -228,6 +228,9 @@ div.widget .slice_container { .m-r-5 { margin-right: 5px; } +.m-r-3 { + margin-right: 3px; +} .m-t-5 { margin-top: 5px; } diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index b32bb928a3c3..593c722d42bb 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -68,6 +68,16 @@ def column_names(self): def main_dttm_col(self): return "timestamp" + @property + def connection(self): + """String representing the context of the Datasource""" + return None + + @property + def schema(self): + """String representing the schema of the Datasource (if it applies)""" + return None + @property def groupby_column_names(self): return sorted([c.column_name for c in self.columns if c.groupby]) @@ -107,6 +117,20 @@ def metrics_combo(self): for m in self.metrics], key=lambda x: x[1]) + @property + def short_data(self): + """Data representation of the datasource sent to the frontend""" + return { + 'edit_url': self.url, + 'id': self.id, + 'uid': self.uid, + 'schema': self.schema, + 'name': self.name, + 'type': self.type, + 'connection': self.connection, + 'creator': str(self.created_by), + } + @property def data(self): """Data representation of the datasource sent to the frontend""" diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index cc85a92b7fae..6f88dd14630c 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -354,6 +354,10 @@ class DruidDatasource(Model, BaseDatasource): def database(self): return self.cluster + @property + def connection(self): + return str(self.database) + @property def num_cols(self): return [c.column_name for c in self.columns if c.is_num] diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 0d06bfbde04c..b836a153e140 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -192,6 +192,10 @@ class SqlaTable(Model, BaseDatasource): def __repr__(self): return self.name + @property + def connection(self): + return str(self.database) + @property def description_markeddown(self): return utils.markdown(self.description) diff --git a/superset/views/core.py b/superset/views/core.py index d5a31260c060..e73ddc838049 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -700,7 +700,8 @@ def json_response(self, obj, status=200): @expose("/datasources/") def datasources(self): datasources = ConnectorRegistry.get_all_datasources(db.session) - datasources = [(str(o.id) + '__' + o.type, repr(o)) for o in datasources] + datasources = [o.short_data for o in datasources] + datasources = sorted(datasources, key=lambda o: o['name']) return self.json_response(datasources) @has_access_api From 90592d3e3d4d2009ae59fcea55c08c932522e154 Mon Sep 17 00:00:00 2001 From: Riccardo Magliocchetti Date: Wed, 2 Aug 2017 07:38:52 +0200 Subject: [PATCH 46/59] sql_lab: re-raise exception in get_sql_results (#3111) As caller expect it to raise an exception instead of returning None. Refs #3075 --- superset/sql_lab.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/sql_lab.py b/superset/sql_lab.py index 55130cdaed61..1d0e89247eb0 100644 --- a/superset/sql_lab.py +++ b/superset/sql_lab.py @@ -98,6 +98,7 @@ def get_sql_results( query.status = QueryStatus.FAILED query.tmp_table_name = None sesh.commit() + raise def execute_sql(ctask, query_id, return_results=True, store_results=False): From 163f4e359cda11f7df60a32c4fbca40cd561a52e Mon Sep 17 00:00:00 2001 From: "Rich @ RadICS" Date: Wed, 2 Aug 2017 17:46:19 +0200 Subject: [PATCH 47/59] Allow 'refresh_immune_slices' (#2974) * Allow 'refresh_immune_slices' * Changed param name, added note in FAQ * Linting --- docs/faq.rst | 21 ++++++++++++++++++- .../javascripts/dashboard/Dashboard.jsx | 13 +++++++----- .../spec/javascripts/dashboard/fixtures.jsx | 1 + superset/models/core.py | 8 +++++++ superset/views/core.py | 2 ++ 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 1777ca4681ad..3fc8cefceb5e 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -107,7 +107,8 @@ never be affected by any dashboard level filtering. "filter_immune_slice_fields": { "177": ["country_name", "__from", "__to"], "32": ["__from", "__to"] - } + }, + "timed_refresh_immune_slices": [324] } In the json blob above, slices 324, 65 and 92 won't be affected by any @@ -124,6 +125,24 @@ But what happens with filtering when dealing with slices coming from different tables or databases? If the column name is shared, the filter will be applied, it's as simple as that. + +How to limit the timed refresh on a dashboard? +---------------------------------------------- +By default, the dashboard timed refresh feature allows you to automatically requery every slice on a dashboard according to a set schedule. Sometimes, however, you won't want all of the slices to be refreshed - especially if some data is slow moving, or run heavy queries. +To exclude specific slices from the timed refresh process, add the ``timed_refresh_immune_slices`` key to the dashboard ``JSON Metadata`` field: + +..code:: + + { + "filter_immune_slices": [], + "expanded_slices": {}, + "filter_immune_slice_fields": {}, + "timed_refresh_immune_slices": [324] + } + +In the example above, if a timed refresh is set for the dashboard, then every slice except 324 will be automatically requeried on schedule. + + Why does fabmanager or superset freezed/hung/not responding when started (my home directory is NFS mounted)? ----------------------------------------------------------------------------------------- superset creates and uses an sqlite database at ``~/.superset/superset.db``. Sqlite is known to `don't work well if used on NFS`__ due to broken file locking implementation on NFS. diff --git a/superset/assets/javascripts/dashboard/Dashboard.jsx b/superset/assets/javascripts/dashboard/Dashboard.jsx index 8b0a0e1c6911..b6c97b68ecf6 100644 --- a/superset/assets/javascripts/dashboard/Dashboard.jsx +++ b/superset/assets/javascripts/dashboard/Dashboard.jsx @@ -245,15 +245,18 @@ export function dashboardContainer(dashboard, datasources, userid) { startPeriodicRender(interval) { this.stopPeriodicRender(); const dash = this; + const immune = this.metadata.timed_refresh_immune_slices || []; const maxRandomDelay = Math.max(interval * 0.2, 5000); const refreshAll = () => { dash.sliceObjects.forEach((slice) => { const force = !dash.firstLoad; - setTimeout(() => { - slice.render(force); - }, - // Randomize to prevent all widgets refreshing at the same time - maxRandomDelay * Math.random()); + if (immune.indexOf(slice.data.slice_id) === -1) { + setTimeout(() => { + slice.render(force); + }, + // Randomize to prevent all widgets refreshing at the same time + maxRandomDelay * Math.random()); + } }); dash.firstLoad = false; }; diff --git a/superset/assets/spec/javascripts/dashboard/fixtures.jsx b/superset/assets/spec/javascripts/dashboard/fixtures.jsx index 7ac259e9416b..7c822d78f985 100644 --- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx +++ b/superset/assets/spec/javascripts/dashboard/fixtures.jsx @@ -43,6 +43,7 @@ export const dashboardData = { css: '', metadata: { filter_immune_slices: [], + timed_refresh_immune_slices: [], filter_immune_slice_fields: {}, expanded_slices: {}, }, diff --git a/superset/models/core.py b/superset/models/core.py index 5527f11d4529..43cbeff5ae47 100644 --- a/superset/models/core.py +++ b/superset/models/core.py @@ -411,6 +411,7 @@ def alter_positions(dashboard, old_to_new_slc_id_dict): slices = copy(dashboard_to_import.slices) old_to_new_slc_id_dict = {} new_filter_immune_slices = [] + new_timed_refresh_immune_slices = [] new_expanded_slices = {} i_params_dict = dashboard_to_import.params_dict for slc in slices: @@ -424,6 +425,10 @@ def alter_positions(dashboard, old_to_new_slc_id_dict): if ('filter_immune_slices' in i_params_dict and old_slc_id_str in i_params_dict['filter_immune_slices']): new_filter_immune_slices.append(new_slc_id_str) + if ('timed_refresh_immune_slices' in i_params_dict and + old_slc_id_str in + i_params_dict['timed_refresh_immune_slices']): + new_timed_refresh_immune_slices.append(new_slc_id_str) if ('expanded_slices' in i_params_dict and old_slc_id_str in i_params_dict['expanded_slices']): new_expanded_slices[new_slc_id_str] = ( @@ -446,6 +451,9 @@ def alter_positions(dashboard, old_to_new_slc_id_dict): if new_filter_immune_slices: dashboard_to_import.alter_params( filter_immune_slices=new_filter_immune_slices) + if new_timed_refresh_immune_slices: + dashboard_to_import.alter_params( + timed_refresh_immune_slices=new_timed_refresh_immune_slices) new_slices = session.query(Slice).filter( Slice.id.in_(old_to_new_slc_id_dict.values())).all() diff --git a/superset/views/core.py b/superset/views/core.py index e73ddc838049..a10e8848e2de 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -1343,6 +1343,8 @@ def _set_dash_metadata(dashboard, data): if 'filter_immune_slices' not in md: md['filter_immune_slices'] = [] + if 'timed_refresh_immune_slices' not in md: + md['timed_refresh_immune_slices'] = [] if 'filter_immune_slice_fields' not in md: md['filter_immune_slice_fields'] = {} md['expanded_slices'] = data['expanded_slices'] From 91bd38a851754d4a0525d9f49a795943d299cde0 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 2 Aug 2017 16:56:46 -0700 Subject: [PATCH 48/59] 0.19.0 (#3227) --- superset/assets/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset/assets/package.json b/superset/assets/package.json index 3a37efe8c143..1422492bfea5 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -1,6 +1,6 @@ { "name": "superset", - "version": "0.18.5", + "version": "0.19.0", "description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.", "license": "Apache-2.0", "directories": { From 4ea770068b5d09290543b8dc7ed70a81dca54cfb Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Wed, 2 Aug 2017 21:33:27 -0700 Subject: [PATCH 49/59] Allowing to integrate time as a groupby value (#3229) --- .../components/ControlPanelsContainer.jsx | 5 +- .../javascripts/explore/stores/controls.jsx | 57 +++++++++++-------- .../javascripts/explore/stores/store.js | 2 +- .../javascripts/explore/stores/visTypes.js | 7 ++- superset/connectors/sqla/models.py | 4 ++ 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx b/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx index ac82bb490c31..8a2e52819d71 100644 --- a/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx +++ b/superset/assets/javascripts/explore/components/ControlPanelsContainer.jsx @@ -28,6 +28,7 @@ class ControlPanelsContainer extends React.Component { this.getControlData = this.getControlData.bind(this); } getControlData(controlName) { + const control = this.props.controls[controlName]; // Identifying mapStateToProps function to apply (logic can't be in store) let mapF = controls[controlName].mapStateToProps; @@ -38,9 +39,9 @@ class ControlPanelsContainer extends React.Component { } // Applying mapStateToProps if needed if (mapF) { - return Object.assign({}, this.props.controls[controlName], mapF(this.props.exploreState)); + return Object.assign({}, control, mapF(this.props.exploreState, control)); } - return this.props.controls[controlName]; + return control; } sectionsToRender() { return sectionsToRender(this.props.form_data.viz_type, this.props.datasource_type); diff --git a/superset/assets/javascripts/explore/stores/controls.jsx b/superset/assets/javascripts/explore/stores/controls.jsx index 7ef5a3e60e02..3d33873c3c53 100644 --- a/superset/assets/javascripts/explore/stores/controls.jsx +++ b/superset/assets/javascripts/explore/stores/controls.jsx @@ -28,6 +28,36 @@ export const D3_TIME_FORMAT_OPTIONS = [ ['%H:%M:%S', '%H:%M:%S | 01:32:10'], ]; +const timeColumnOption = { + verbose_name: 'Time', + column_name: '__timestamp', + description: ( + 'A reference to the [Time] configuration, taking granularity into ' + + 'account'), +}; + +const groupByControl = { + type: 'SelectControl', + multi: true, + label: 'Group by', + default: [], + includeTime: false, + description: 'One or many controls to group by', + optionRenderer: c => , + valueRenderer: c => , + valueKey: 'column_name', + mapStateToProps: (state, control) => { + const newState = {}; + if (state.datasource) { + newState.options = state.datasource.columns.filter(c => c.groupby); + if (control && control.includeTime) { + newState.options.push(timeColumnOption); + } + } + return newState; + }, +}; + export const controls = { datasource: { type: 'DatasourceControl', @@ -323,33 +353,12 @@ export const controls = { 'to find in the [country] column', }, - groupby: { - type: 'SelectControl', - multi: true, - label: 'Group by', - default: [], - description: 'One or many controls to group by', - optionRenderer: c => , - valueRenderer: c => , - valueKey: 'column_name', - mapStateToProps: state => ({ - options: (state.datasource) ? state.datasource.columns.filter(c => c.groupby) : [], - }), - }, + groupby: groupByControl, - columns: { - type: 'SelectControl', - multi: true, + columns: Object.assign({}, groupByControl, { label: 'Columns', - default: [], description: 'One or many controls to pivot as columns', - optionRenderer: c => , - valueRenderer: c => , - valueKey: 'column_name', - mapStateToProps: state => ({ - options: (state.datasource) ? state.datasource.columns : [], - }), - }, + }), all_columns: { type: 'SelectControl', diff --git a/superset/assets/javascripts/explore/stores/store.js b/superset/assets/javascripts/explore/stores/store.js index 2cd2874c266f..af809ed2fa51 100644 --- a/superset/assets/javascripts/explore/stores/store.js +++ b/superset/assets/javascripts/explore/stores/store.js @@ -52,7 +52,7 @@ export function getControlsState(state, form_data) { controlNames.forEach((k) => { const control = Object.assign({}, controls[k], controlOverrides[k]); if (control.mapStateToProps) { - Object.assign(control, control.mapStateToProps(state)); + Object.assign(control, control.mapStateToProps(state, control)); delete control.mapStateToProps; } diff --git a/superset/assets/javascripts/explore/stores/visTypes.js b/superset/assets/javascripts/explore/stores/visTypes.js index 4dd2700c5ea9..1df8e11c7267 100644 --- a/superset/assets/javascripts/explore/stores/visTypes.js +++ b/superset/assets/javascripts/explore/stores/visTypes.js @@ -337,10 +337,15 @@ export const visTypes = { controlSetRows: [ ['groupby', 'columns'], ['metrics', 'pandas_aggfunc'], - ['number_format', 'combine_metric', 'pivot_margins'], + ['number_format', 'combine_metric'], + ['pivot_margins'], ], }, ], + controlOverrides: { + groupby: { includeTime: true }, + columns: { includeTime: true }, + }, }, separator: { diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index b836a153e140..6759d2c90792 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -376,6 +376,10 @@ def get_sqla_query( # sqla template_processor = self.get_template_processor(**template_kwargs) db_engine_spec = self.database.db_engine_spec + if DTTM_ALIAS in groupby: + groupby.remove(DTTM_ALIAS) + is_timeseries = True + # For backward compatibility if granularity not in self.dttm_cols: granularity = self.main_dttm_col From 5278b53218187992a089ed734ce90daf9551d44c Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 3 Aug 2017 00:04:16 -0700 Subject: [PATCH 50/59] [pivot] add support for in Pivot on Druid (#3230) --- superset/connectors/druid/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 6f88dd14630c..53aeb5a2a84f 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -817,6 +817,11 @@ def run_query( # noqa / druid """ # TODO refactor into using a TBD Query object client = client or self.cluster.get_pydruid_client() + + if DTTM_ALIAS in groupby: + groupby.remove(DTTM_ALIAS) + is_timeseries = True + if not is_timeseries: granularity = 'all' inner_from_dttm = inner_from_dttm or from_dttm From 4c3313b01cb508ced8519a68f6479db423974929 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Thu, 3 Aug 2017 15:42:26 -0700 Subject: [PATCH 51/59] Handle Time at query_obj generation time (#3236) As opposed to in the within itself --- superset/connectors/druid/models.py | 4 ---- superset/connectors/sqla/models.py | 4 ---- superset/viz.py | 21 +++++++++++++-------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index 53aeb5a2a84f..335f9d26b1fb 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -818,10 +818,6 @@ def run_query( # noqa / druid # TODO refactor into using a TBD Query object client = client or self.cluster.get_pydruid_client() - if DTTM_ALIAS in groupby: - groupby.remove(DTTM_ALIAS) - is_timeseries = True - if not is_timeseries: granularity = 'all' inner_from_dttm = inner_from_dttm or from_dttm diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 6759d2c90792..b836a153e140 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -376,10 +376,6 @@ def get_sqla_query( # sqla template_processor = self.get_template_processor(**template_kwargs) db_engine_spec = self.database.db_engine_spec - if DTTM_ALIAS in groupby: - groupby.remove(DTTM_ALIAS) - is_timeseries = True - # For backward compatibility if granularity not in self.dttm_cols: granularity = self.main_dttm_col diff --git a/superset/viz.py b/superset/viz.py index de1f635ec533..942c670f7443 100755 --- a/superset/viz.py +++ b/superset/viz.py @@ -114,6 +114,13 @@ def query_obj(self): form_data = self.form_data groupby = form_data.get("groupby") or [] metrics = form_data.get("metrics") or [] + columns = form_data.get("columns") or [] + groupby = list(set(groupby + columns)) + + is_timeseries = self.is_timeseries + if DTTM_ALIAS in groupby: + groupby.remove(DTTM_ALIAS) + is_timeseries = True # extra_filters are temporary/contextual filters that are external # to the slice definition. We use those for dynamic interactive @@ -173,7 +180,7 @@ def query_obj(self): 'granularity': granularity, 'from_dttm': from_dttm, 'to_dttm': to_dttm, - 'is_timeseries': self.is_timeseries, + 'is_timeseries': is_timeseries, 'groupby': groupby, 'metrics': metrics, 'row_limit': row_limit, @@ -385,9 +392,7 @@ def query_obj(self): if ( any(v in groupby for v in columns) or any(v in columns for v in groupby)): - raise Exception("groupby and columns can't overlap") - - d['groupby'] = list(set(groupby) | set(columns)) + raise Exception(""""Group By" and "Columns" can't overlap""") return d def get_data(self, df): @@ -1082,10 +1087,10 @@ class DistributionBarViz(DistributionPieViz): def query_obj(self): d = super(DistributionBarViz, self).query_obj() # noqa fd = self.form_data - gb = fd.get('groupby') or [] - cols = fd.get('columns') or [] - d['groupby'] = set(gb + cols) - if len(d['groupby']) < len(gb) + len(cols): + if ( + len(self.groupby) < + len(fd.get('groupby') or []) + len(fd.get('columns') or []) + ): raise Exception("Can't have overlap between Series and Breakdowns") if not self.metrics: raise Exception("Pick at least one metric") From 0191fa58c87c878147ce3e6d4645c587dcd268e8 Mon Sep 17 00:00:00 2001 From: "Rich @ RadICS" Date: Fri, 4 Aug 2017 17:51:40 +0200 Subject: [PATCH 52/59] SUPERSET_HOME enviroment variable (#3238) * Change hardcoded references to 'User' security model to allow custom class override * Add SUPERSET_HOME environment variable --- docs/faq.rst | 6 +++--- superset/config.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/faq.rst b/docs/faq.rst index 3fc8cefceb5e..12a019895be9 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -145,13 +145,13 @@ In the example above, if a timed refresh is set for the dashboard, then every sl Why does fabmanager or superset freezed/hung/not responding when started (my home directory is NFS mounted)? ----------------------------------------------------------------------------------------- -superset creates and uses an sqlite database at ``~/.superset/superset.db``. Sqlite is known to `don't work well if used on NFS`__ due to broken file locking implementation on NFS. +By default, superset creates and uses an sqlite database at ``~/.superset/superset.db``. Sqlite is known to `don't work well if used on NFS`__ due to broken file locking implementation on NFS. __ https://www.sqlite.org/lockingv3.html -One work around is to create a symlink from ~/.superset to a directory located on a non-NFS partition. +You can override this path using the ``SUPERSET_HOME`` environment variable. -Another work around is to change where superset stores the sqlite database by adding ``SQLALCHEMY_DATABASE_URI = 'sqlite:////new/localtion/superset.db'`` in superset_config.py (create the file if needed), then adding the directory where superset_config.py lives to PYTHONPATH environment variable (e.g. ``export PYTHONPATH=/opt/logs/sandbox/airbnb/``). +Another work around is to change where superset stores the sqlite database by adding ``SQLALCHEMY_DATABASE_URI = 'sqlite:////new/location/superset.db'`` in superset_config.py (create the file if needed), then adding the directory where superset_config.py lives to PYTHONPATH environment variable (e.g. ``export PYTHONPATH=/opt/logs/sandbox/airbnb/``). How do I add new columns to an existing table --------------------------------------------- diff --git a/superset/config.py b/superset/config.py index 47126f8bd4f4..bc91edd07b09 100644 --- a/superset/config.py +++ b/superset/config.py @@ -24,7 +24,10 @@ STATS_LOGGER = DummyStatsLogger() BASE_DIR = os.path.abspath(os.path.dirname(__file__)) -DATA_DIR = os.path.join(os.path.expanduser('~'), '.superset') +if 'SUPERSET_HOME' in os.environ: + DATA_DIR = os.environ['SUPERSET_HOME'] +else: + DATA_DIR = os.path.join(os.path.expanduser('~'), '.superset') if not os.path.exists(DATA_DIR): os.makedirs(DATA_DIR) From 2ef9bfed2057144e33cf36c9447337742bab677f Mon Sep 17 00:00:00 2001 From: "Shao-Yen \"Fred\" Cheng" Date: Fri, 4 Aug 2017 09:05:50 -0700 Subject: [PATCH 53/59] [bug fix] Fix to #3137 and #3239 (#3240) * #3137 add the leftover code from #3138 for fixing issue #3137 * #3239 Use slice id as the key instead of sliceName --- .../assets/javascripts/dashboard/components/SliceAdder.jsx | 7 ++++++- .../explore/components/ControlPanelsContainer.jsx | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/superset/assets/javascripts/dashboard/components/SliceAdder.jsx b/superset/assets/javascripts/dashboard/components/SliceAdder.jsx index a96effef4b19..cb9206619e87 100644 --- a/superset/assets/javascripts/dashboard/components/SliceAdder.jsx +++ b/superset/assets/javascripts/dashboard/components/SliceAdder.jsx @@ -129,9 +129,14 @@ class SliceAdder extends React.Component { height="auto" >