This is an automated email from the ASF dual-hosted git repository.

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


The following commit(s) were added to refs/heads/master by this push:
     new 8c0551e  [SIP-5] Open a new /api/v1/query endpoint that takes 
query_obj (#6220)
8c0551e is described below

commit 8c0551ed4608f086c489a6ab1c7a8afe2efd6ef0
Author: Christine Chambers <christine.d.h...@gmail.com>
AuthorDate: Wed Nov 7 17:23:52 2018 -0800

    [SIP-5] Open a new /api/v1/query endpoint that takes query_obj (#6220)
    
    * [SIP-5] Open a new /api/v1/query endpoint that takes query_obj
    
    - Introduce a new handle_superset_exception decorator to avoid repeating 
the logic for catching SupersetExceptions
    - Create a query_obj_backfill method that takes form_data and constructs a 
query_obj that will be constructed in the client in the future. Use the 
backfill in explore_json.
    - Create a new /api/v1/query endpoint that takes query_obj only and returns 
the payload data. Note the query_obj is constructed in the client. The endpoint 
currently only handles query_obj for table view viz (we'll be adding support to 
new viz types as we go).
    - Unit test to verify the new endpoint for table view
    
    * fix tests and lint errors
    
    * - Move the new query endpoint into its own api.py view.
    - Create QueryObject and QueryContext class to encapsulate query_object to 
be built from the client and additional info (e.g. datasource) needed to get 
the data payload for a given query
    - Remove the query_obj_backfill as we'll start building the first 
query_object on the client so it no longer makes sense to have a short-lived 
backfill for the matter of days.
    
    * Fixing lint and test errors
    
    * Fixing additional lint error from the previous rebase.
    
    * fixing additional lint error
    
    * addressing additional pr comments
    
    * Make /query accept a list of queries in the query_context object.
    
    * fixing a lint error
    
    * - Move time_shift based calculation and since, until check into util
    - Add typing info for get_since_until
    - Add new unit tests to verify time_shift calculation and the since until 
check
---
 .pylintrc                        |   2 +-
 superset/common/__init__.py      |   0
 superset/common/query_context.py |  27 ++++++++++
 superset/common/query_object.py  |  47 ++++++++++++++++
 superset/exceptions.py           |   9 +++-
 superset/models/core.py          |   2 +-
 superset/security.py             |   8 +++
 superset/utils/core.py           |  27 +++++++---
 superset/views/__init__.py       |   1 +
 superset/views/api.py            |  31 +++++++++++
 superset/views/base.py           |  31 ++++++++++-
 superset/views/core.py           | 114 +++++++++++----------------------------
 superset/viz.py                  |  13 ++++-
 tests/core_tests.py              |  23 ++++++++
 tests/utils_tests.py             |  34 ++++++------
 15 files changed, 256 insertions(+), 113 deletions(-)

diff --git a/.pylintrc b/.pylintrc
index d95e1a8..4b7301b 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -232,7 +232,7 @@ logging-modules=logging
 [MISCELLANEOUS]
 
 # List of note tags to take in consideration, separated by a comma.
-notes=FIXME,XXX,TODO
+notes=FIXME,XXX
 
 
 [SIMILARITIES]
diff --git a/superset/common/__init__.py b/superset/common/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/superset/common/query_context.py b/superset/common/query_context.py
new file mode 100644
index 0000000..59bd8b5
--- /dev/null
+++ b/superset/common/query_context.py
@@ -0,0 +1,27 @@
+# pylint: disable=R
+from typing import Dict, List
+
+from superset import db
+from superset.connectors.connector_registry import ConnectorRegistry
+from .query_object import QueryObject
+
+
+class QueryContext:
+    """
+    The query context contains the query object and additional fields necessary
+    to retrieve the data payload for a given viz.
+    """
+    # TODO: Type datasource and query_object dictionary with TypedDict when it 
becomes
+    # a vanilla python type https://github.com/python/mypy/issues/5288
+    def __init__(
+            self,
+            datasource: Dict,
+            queries: List[Dict],
+    ):
+        self.datasource = 
ConnectorRegistry.get_datasource(datasource.get('type'),
+                                                           
datasource.get('id'),
+                                                           db.session)
+        self.queries = list(map(lambda query_obj: QueryObject(**query_obj), 
queries))
+
+    def get_data(self):
+        raise NotImplementedError()
diff --git a/superset/common/query_object.py b/superset/common/query_object.py
new file mode 100644
index 0000000..2052ff9
--- /dev/null
+++ b/superset/common/query_object.py
@@ -0,0 +1,47 @@
+# pylint: disable=R
+from typing import Dict, List, Optional, Union
+
+from superset import app
+from superset.utils import core as utils
+
+# TODO: Type Metrics dictionary with TypedDict when it becomes a vanilla 
python type
+# https://github.com/python/mypy/issues/5288
+Metric = Union[str, Dict]
+
+
+class QueryObject:
+    """
+    The query object's schema matches the interfaces of DB connectors like sqla
+    and druid. The query objects are constructed on the client.
+    """
+    def __init__(
+            self,
+            granularity: str,
+            groupby: List[str],
+            metrics: List[Metric],
+            filters: List[str],
+            time_range: Optional[str] = None,
+            time_shift: Optional[str] = None,
+            is_timeseries: bool = False,
+            row_limit: int = app.config.get('ROW_LIMIT'),
+            limit: int = 0,
+            timeseries_limit_metric: Optional[Metric] = None,
+            order_desc: bool = True,
+            extras: Optional[Dict] = None,
+    ):
+        self.granularity = granularity
+        self.from_dttm, self.to_dttm = utils.get_since_until(time_range, 
time_shift)
+        self.is_timeseries = is_timeseries
+        self.groupby = groupby
+        self.metrics = metrics
+        self.row_limit = row_limit
+        self.filter = filters
+        self.timeseries_limit = int(limit)
+        self.timeseries_limit_metric = timeseries_limit_metric
+        self.order_desc = order_desc
+        self.prequeries = []
+        self.is_prequery = False
+        self.extras = extras
+
+    def to_dict(self):
+        raise NotImplementedError()
diff --git a/superset/exceptions.py b/superset/exceptions.py
index 51d0e48..842e946 100644
--- a/superset/exceptions.py
+++ b/superset/exceptions.py
@@ -4,13 +4,20 @@
 class SupersetException(Exception):
     status = 500
 
+    def __init__(self, msg):
+        super(SupersetException, self).__init__(msg)
+
 
 class SupersetTimeoutException(SupersetException):
     pass
 
 
 class SupersetSecurityException(SupersetException):
-    pass
+    status = 401
+
+    def __init__(self, msg, link=None):
+        super(SupersetSecurityException, self).__init__(msg)
+        self.link = link
 
 
 class MetricPermException(SupersetException):
diff --git a/superset/models/core.py b/superset/models/core.py
index 5890fed..b21c8c8 100644
--- a/superset/models/core.py
+++ b/superset/models/core.py
@@ -198,7 +198,7 @@ class Slice(Model, AuditMixinNullable, ImportMixin):
         d = json.loads(self.params)
         viz_class = viz_types[self.viz_type]
         # pylint: disable=no-member
-        return viz_class(self.datasource, form_data=d)
+        return viz_class(datasource=self.datasource, form_data=d)
 
     @property
     def description_markeddown(self):
diff --git a/superset/security.py b/superset/security.py
index 4b58cda..f6ac6b7 100644
--- a/superset/security.py
+++ b/superset/security.py
@@ -9,6 +9,7 @@ from sqlalchemy import or_
 
 from superset import sql_parse
 from superset.connectors.connector_registry import ConnectorRegistry
+from superset.exceptions import SupersetSecurityException
 
 READ_ONLY_MODEL_VIEWS = {
     'DatabaseAsync',
@@ -428,3 +429,10 @@ class SupersetSecurityManager(SecurityManager):
                     view_menu_id=view_menu.id,
                 ),
             )
+
+    def assert_datasource_permission(self, datasource, user=None):
+        if not self.datasource_access(datasource, user):
+            raise SupersetSecurityException(
+                self.get_datasource_access_error_msg(datasource),
+                self.get_datasource_access_link(datasource),
+            )
diff --git a/superset/utils/core.py b/superset/utils/core.py
index cecd082..c0960a7 100644
--- a/superset/utils/core.py
+++ b/superset/utils/core.py
@@ -15,6 +15,7 @@ import os
 import signal
 import smtplib
 import sys
+from typing import Optional
 import uuid
 import zlib
 
@@ -24,6 +25,7 @@ from dateutil.parser import parse
 from dateutil.relativedelta import relativedelta
 from flask import flash, g, Markup, render_template
 from flask_babel import gettext as __
+from flask_babel import lazy_gettext as _
 from flask_caching import Cache
 import markdown as md
 import numpy
@@ -271,7 +273,7 @@ def parse_human_timedelta(s):
     """
     cal = parsedatetime.Calendar()
     dttm = dttm_from_timtuple(datetime.now().timetuple())
-    d = cal.parse(s, dttm)[0]
+    d = cal.parse(s or '', dttm)[0]
     d = datetime(d.tm_year, d.tm_mon, d.tm_mday, d.tm_hour, d.tm_min, d.tm_sec)
     return d - dttm
 
@@ -885,8 +887,12 @@ def ensure_path_exists(path):
             raise
 
 
-def get_since_until(form_data):
-    """Return `since` and `until` from form_data.
+def get_since_until(time_range: Optional[str] = None,
+                    since: Optional[str] = None,
+                    until: Optional[str] = None,
+                    time_shift: Optional[str] = None) -> (datetime, datetime):
+    """Return `since` and `until` date time tuple from string representations 
of
+    time_range, since, until and time_shift.
 
     This functiom supports both reading the keys separately (from `since` and
     `until`), as well as the new `time_range` key. Valid formats are:
@@ -919,8 +925,7 @@ def get_since_until(form_data):
         'Last year': (today - relativedelta(years=1), today),
     }
 
-    if 'time_range' in form_data:
-        time_range = form_data['time_range']
+    if time_range:
         if separator in time_range:
             since, until = time_range.split(separator, 1)
             if since and since not in common_time_frames:
@@ -940,11 +945,19 @@ def get_since_until(form_data):
                 since = today
                 until = today + relativedelta(**{grain: int(num)})
     else:
-        since = form_data.get('since', '')
+        since = since or ''
         if since:
             since = add_ago_to_since(since)
         since = parse_human_datetime(since)
-        until = parse_human_datetime(form_data.get('until', 'now'))
+        until = parse_human_datetime(until or 'now')
+
+    if time_shift:
+        time_shift = parse_human_timedelta(time_shift)
+        since = since if since is None else (since - time_shift)
+        until = until if until is None else (until - time_shift)
+
+    if since and until and since > until:
+        raise ValueError(_('From date cannot be larger than to date'))
 
     return since, until
 
diff --git a/superset/views/__init__.py b/superset/views/__init__.py
index 506f0d7..eed1ff2 100644
--- a/superset/views/__init__.py
+++ b/superset/views/__init__.py
@@ -1,4 +1,5 @@
 from . import base  # noqa
+from . import api # noqa
 from . import core  # noqa
 from . import sql_lab  # noqa
 from . import annotations # noqa
diff --git a/superset/views/api.py b/superset/views/api.py
new file mode 100644
index 0000000..c1d2714
--- /dev/null
+++ b/superset/views/api.py
@@ -0,0 +1,31 @@
+# pylint: disable=R
+import json
+
+from flask import g, request
+from flask_appbuilder import expose
+from flask_appbuilder.security.decorators import has_access_api
+
+from superset import appbuilder, security_manager
+from superset.common.query_context import QueryContext
+from superset.models.core import Log
+from .base import api, BaseSupersetView, data_payload_response, 
handle_superset_exception
+
+
+class Api(BaseSupersetView):
+    @Log.log_this
+    @api
+    @handle_superset_exception
+    @has_access_api
+    @expose('/v1/query/', methods=['POST'])
+    def query(self):
+        """
+        Takes a query_obj constructed in the client and returns payload data 
response
+        for the given query_obj.
+        """
+        query_context = 
QueryContext(**json.loads(request.form.get('query_context')))
+        
security_manager.assert_datasource_permission(query_context.datasource, g.user)
+        payload_json = query_context.get_data()
+        return data_payload_response(payload_json)
+
+
+appbuilder.add_view_no_menu(Api)
diff --git a/superset/views/base.py b/superset/views/base.py
index 4b4926c..657242f 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -16,7 +16,7 @@ import simplejson as json
 import yaml
 
 from superset import conf, db, security_manager
-from superset.exceptions import SupersetSecurityException
+from superset.exceptions import SupersetException, SupersetSecurityException
 from superset.translations.utils import get_language_pack
 from superset.utils import core as utils
 
@@ -53,6 +53,15 @@ def json_error_response(msg=None, status=500, 
stacktrace=None, payload=None, lin
         status=status, mimetype='application/json')
 
 
+def json_success(json_msg, status=200):
+    return Response(json_msg, status=status, mimetype='application/json')
+
+
+def data_payload_response(payload_json, has_error=False):
+    status = 400 if has_error else 200
+    return json_success(payload_json, status=status)
+
+
 def generate_download_headers(extension, filename=None):
     filename = filename if filename else 
datetime.now().strftime('%Y%m%d_%H%M%S')
     content_disp = 'attachment; filename={}.{}'.format(filename, extension)
@@ -77,6 +86,26 @@ def api(f):
     return functools.update_wrapper(wraps, f)
 
 
+def handle_superset_exception(f):
+    """
+    A decorator to catch superset exceptions. Use it after the @api decorator 
above
+    so superset exception handler is triggered before the handler for generic 
exceptions.
+    """
+    def wraps(self, *args, **kwargs):
+        try:
+            return f(self, *args, **kwargs)
+        except SupersetSecurityException as sse:
+            logging.exception(sse)
+            return json_error_response(utils.error_msg_from_exception(sse),
+                                       status=sse.status,
+                                       link=sse.link)
+        except SupersetException as se:
+            logging.exception(se)
+            return json_error_response(utils.error_msg_from_exception(se),
+                                       status=se.status)
+    return functools.update_wrapper(wraps, f)
+
+
 def get_datasource_exist_error_msg(full_name):
     return __('Datasource %(name)s already exists', name=full_name)
 
diff --git a/superset/views/core.py b/superset/views/core.py
index 1f11e70..b53ad6f 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -45,9 +45,9 @@ from superset.utils import dashboard_import_export
 from .base import (
     api, BaseSupersetView,
     check_ownership,
-    CsvResponse, DeleteMixin,
-    generate_download_headers, get_error_msg,
-    json_error_response, SupersetFilter, SupersetModelView, YamlExportMixin,
+    CsvResponse, data_payload_response, DeleteMixin, generate_download_headers,
+    get_error_msg, handle_superset_exception, json_error_response, 
json_success,
+    SupersetFilter, SupersetModelView, YamlExportMixin,
 )
 from .utils import bootstrap_user_data
 
@@ -79,10 +79,6 @@ def get_database_access_error_msg(database_name):
               '`all_datasource_access` permission', name=database_name)
 
 
-def json_success(json_msg, status=200):
-    return Response(json_msg, status=status, mimetype='application/json')
-
-
 def is_owner(obj, user):
     """ Check if user is owner of the slice """
     return obj and user in obj.owners
@@ -1118,29 +1114,19 @@ class Superset(BaseSupersetView):
             'data': viz_obj.get_samples(),
         })
 
+    @handle_superset_exception
     def generate_json(
             self, datasource_type, datasource_id, form_data,
             csv=False, query=False, force=False, results=False,
             samples=False,
     ):
-        try:
-            viz_obj = self.get_viz(
-                datasource_type=datasource_type,
-                datasource_id=datasource_id,
-                form_data=form_data,
-                force=force,
-            )
-        except Exception as e:
-            logging.exception(e)
-            return json_error_response(
-                utils.error_msg_from_exception(e),
-                stacktrace=traceback.format_exc())
-
-        if not security_manager.datasource_access(viz_obj.datasource, g.user):
-            return json_error_response(
-                
security_manager.get_datasource_access_error_msg(viz_obj.datasource),
-                status=404,
-                
link=security_manager.get_datasource_access_link(viz_obj.datasource))
+        viz_obj = self.get_viz(
+            datasource_type=datasource_type,
+            datasource_id=datasource_id,
+            form_data=form_data,
+            force=force,
+        )
+        security_manager.assert_datasource_permission(viz_obj.datasource, 
g.user)
 
         if csv:
             return CsvResponse(
@@ -1158,43 +1144,24 @@ class Superset(BaseSupersetView):
         if samples:
             return self.get_samples(viz_obj)
 
-        try:
-            payload = viz_obj.get_payload()
-        except SupersetException as se:
-            logging.exception(se)
-            return json_error_response(utils.error_msg_from_exception(se),
-                                       status=se.status)
-        except Exception as e:
-            logging.exception(e)
-            return json_error_response(utils.error_msg_from_exception(e))
-
-        status = 200
-        if (
-            payload.get('status') == QueryStatus.FAILED or
-            payload.get('error') is not None
-        ):
-            status = 400
-
-        return json_success(viz_obj.json_dumps(payload), status=status)
+        payload = viz_obj.get_payload()
+        return 
data_payload_response(*viz_obj.payload_json_and_has_error(payload))
 
     @log_this
+    @api
     @has_access_api
     @expose('/slice_json/<slice_id>')
     def slice_json(self, slice_id):
-        try:
-            form_data, slc = self.get_form_data(slice_id, use_slice_data=True)
-            datasource_type = slc.datasource.type
-            datasource_id = slc.datasource.id
+        form_data, slc = self.get_form_data(slice_id, use_slice_data=True)
+        datasource_type = slc.datasource.type
+        datasource_id = slc.datasource.id
 
-        except Exception as e:
-            return json_error_response(
-                utils.error_msg_from_exception(e),
-                stacktrace=traceback.format_exc())
         return self.generate_json(datasource_type=datasource_type,
                                   datasource_id=datasource_id,
                                   form_data=form_data)
 
     @log_this
+    @api
     @has_access_api
     @expose('/annotation_json/<layer_id>')
     def annotation_json(self, layer_id):
@@ -1209,17 +1176,11 @@ class Superset(BaseSupersetView):
             form_data=form_data,
             force=False,
         )
-        try:
-            payload = viz_obj.get_payload()
-        except Exception as e:
-            logging.exception(e)
-            return json_error_response(utils.error_msg_from_exception(e))
-        status = 200
-        if payload.get('status') == QueryStatus.FAILED:
-            status = 400
-        return json_success(viz_obj.json_dumps(payload), status=status)
+        payload = viz_obj.get_payload()
+        return 
data_payload_response(*viz_obj.payload_json_and_has_error(payload))
 
     @log_this
+    @api
     @has_access_api
     @expose('/explore_json/<datasource_type>/<datasource_id>/', 
methods=['GET', 'POST'])
     @expose('/explore_json/', methods=['GET', 'POST'])
@@ -1239,15 +1200,10 @@ class Superset(BaseSupersetView):
         samples = request.args.get('samples') == 'true'
         force = request.args.get('force') == 'true'
 
-        try:
-            form_data = self.get_form_data()[0]
-            datasource_id, datasource_type = self.datasource_info(
-                datasource_id, datasource_type, form_data)
-        except Exception as e:
-            logging.exception(e)
-            return json_error_response(
-                utils.error_msg_from_exception(e),
-                stacktrace=traceback.format_exc())
+        form_data = self.get_form_data()[0]
+        datasource_id, datasource_type = self.datasource_info(
+            datasource_id, datasource_type, form_data)
+
         return self.generate_json(
             datasource_type=datasource_type,
             datasource_id=datasource_id,
@@ -1403,6 +1359,7 @@ class Superset(BaseSupersetView):
             standalone_mode=standalone)
 
     @api
+    @handle_superset_exception
     @has_access_api
     @expose('/filter/<datasource_type>/<datasource_id>/<column>/')
     def filter(self, datasource_type, datasource_id, column):
@@ -1419,10 +1376,7 @@ class Superset(BaseSupersetView):
             datasource_type, datasource_id, db.session)
         if not datasource:
             return json_error_response(DATASOURCE_MISSING_ERR)
-        if not security_manager.datasource_access(datasource):
-            return json_error_response(
-                security_manager.get_datasource_access_error_msg(datasource))
-
+        security_manager.assert_datasource_permission(datasource)
         payload = json.dumps(
             datasource.values_for_column(
                 column,
@@ -2660,6 +2614,8 @@ class Superset(BaseSupersetView):
         logging.info('Ready to return response')
         return response
 
+    @api
+    @handle_superset_exception
     @has_access
     @expose('/fetch_datasource_metadata')
     @log_this
@@ -2673,10 +2629,7 @@ class Superset(BaseSupersetView):
             return json_error_response(DATASOURCE_MISSING_ERR)
 
         # Check permission for datasource
-        if not security_manager.datasource_access(datasource):
-            return json_error_response(
-                security_manager.get_datasource_access_error_msg(datasource),
-                link=security_manager.get_datasource_access_link(datasource))
+        security_manager.assert_datasource_permission(datasource)
         return json_success(json.dumps(datasource.data))
 
     @expose('/queries/<last_updated_ms>')
@@ -2853,6 +2806,7 @@ class Superset(BaseSupersetView):
         )
 
     @api
+    @handle_superset_exception
     @has_access_api
     @expose('/slice_query/<slice_id>/')
     def slice_query(self, slice_id):
@@ -2861,11 +2815,7 @@ class Superset(BaseSupersetView):
         get the database query string for this slice
         """
         viz_obj = self.get_viz(slice_id)
-        if not security_manager.datasource_access(viz_obj.datasource):
-            return json_error_response(
-                
security_manager.get_datasource_access_error_msg(viz_obj.datasource),
-                status=401,
-                
link=security_manager.get_datasource_access_link(viz_obj.datasource))
+        security_manager.assert_datasource_permission(viz_obj.datasource)
         return self.get_query_string_response(viz_obj)
 
     @api
diff --git a/superset/viz.py b/superset/viz.py
index d5b6481..fba68ae 100644
--- a/superset/viz.py
+++ b/superset/viz.py
@@ -279,7 +279,9 @@ class BaseViz(object):
         # default order direction
         order_desc = form_data.get('order_desc', True)
 
-        since, until = utils.get_since_until(form_data)
+        since, until = utils.get_since_until(form_data.get('time_range'),
+                                             form_data.get('since'),
+                                             form_data.get('until'))
         time_shift = form_data.get('time_shift', '')
         self.time_shift = utils.parse_human_timedelta(time_shift)
         from_dttm = None if since is None else (since - self.time_shift)
@@ -466,6 +468,11 @@ class BaseViz(object):
             sort_keys=sort_keys,
         )
 
+    def payload_json_and_has_error(self, payload):
+        has_error = payload.get('status') == utils.QueryStatus.FAILED or \
+            payload.get('error') is not None
+        return self.json_dumps(payload), has_error
+
     @property
     def data(self):
         """This is the data object serialized to the js layer"""
@@ -788,7 +795,9 @@ class CalHeatmapViz(BaseViz):
                 for obj in records
             }
 
-        start, end = utils.get_since_until(form_data)
+        start, end = utils.get_since_until(form_data.get('time_range'),
+                                           form_data.get('since'),
+                                           form_data.get('until'))
         if not start or not end:
             raise Exception('Please provide both time bounds (Since and 
Until)')
         domain = form_data.get('domain_granularity')
diff --git a/tests/core_tests.py b/tests/core_tests.py
index 70db1d1..70b6341 100644
--- a/tests/core_tests.py
+++ b/tests/core_tests.py
@@ -86,6 +86,29 @@ class CoreTests(SupersetTestCase):
         qobj['groupby'] = []
         self.assertNotEqual(cache_key, viz.cache_key(qobj))
 
+    def test_api_v1_query_endpoint(self):
+        self.login(username='admin')
+        slc = self.get_slice('Name Cloud', db.session)
+        form_data = slc.form_data
+        data = json.dumps({
+            'datasource': {
+                'id': slc.datasource_id,
+                'type': slc.datasource_type,
+            },
+            'queries': [{
+                'granularity': 'ds',
+                'groupby': ['name'],
+                'metrics': ['sum__num'],
+                'filters': [],
+                'time_range': '{} : {}'.format(form_data.get('since'),
+                                               form_data.get('until')),
+                'limit': 100,
+            }],
+        })
+        # TODO: update once get_data is implemented for QueryObject
+        with self.assertRaises(Exception):
+            self.get_resp('/api/v1/query/', {'query_context': data})
+
     def test_old_slice_json_endpoint(self):
         self.login(username='admin')
         slc = self.get_slice('Girls', db.session)
diff --git a/tests/utils_tests.py b/tests/utils_tests.py
index 5882a02..c942337 100644
--- a/tests/utils_tests.py
+++ b/tests/utils_tests.py
@@ -578,51 +578,49 @@ class UtilsTestCase(unittest.TestCase):
 
     @patch('superset.utils.core.parse_human_datetime', 
mock_parse_human_datetime)
     def test_get_since_until(self):
-        form_data = {}
-        result = get_since_until(form_data)
+        result = get_since_until()
         expected = None, datetime(2016, 11, 7)
         self.assertEqual(result, expected)
 
-        form_data = {'time_range': ' : now'}
-        result = get_since_until(form_data)
+        result = get_since_until(' : now')
         expected = None, datetime(2016, 11, 7)
         self.assertEqual(result, expected)
 
-        form_data = {'time_range': 'yesterday : tomorrow'}
-        result = get_since_until(form_data)
+        result = get_since_until('yesterday : tomorrow')
         expected = datetime(2016, 11, 6), datetime(2016, 11, 8)
         self.assertEqual(result, expected)
 
-        form_data = {'time_range': '2018-01-01T00:00:00 : 2018-12-31T23:59:59'}
-        result = get_since_until(form_data)
+        result = get_since_until('2018-01-01T00:00:00 : 2018-12-31T23:59:59')
         expected = datetime(2018, 1, 1), datetime(2018, 12, 31, 23, 59, 59)
         self.assertEqual(result, expected)
 
-        form_data = {'time_range': 'Last year'}
-        result = get_since_until(form_data)
+        result = get_since_until('Last year')
         expected = datetime(2015, 11, 7), datetime(2016, 11, 7)
         self.assertEqual(result, expected)
 
-        form_data = {'time_range': 'Last 5 months'}
-        result = get_since_until(form_data)
+        result = get_since_until('Last 5 months')
         expected = datetime(2016, 6, 7), datetime(2016, 11, 7)
         self.assertEqual(result, expected)
 
-        form_data = {'time_range': 'Next 5 months'}
-        result = get_since_until(form_data)
+        result = get_since_until('Next 5 months')
         expected = datetime(2016, 11, 7), datetime(2017, 4, 7)
         self.assertEqual(result, expected)
 
-        form_data = {'since': '5 days'}
-        result = get_since_until(form_data)
+        result = get_since_until(since='5 days')
         expected = datetime(2016, 11, 2), datetime(2016, 11, 7)
         self.assertEqual(result, expected)
 
-        form_data = {'since': '5 days ago', 'until': 'tomorrow'}
-        result = get_since_until(form_data)
+        result = get_since_until(since='5 days ago', until='tomorrow')
         expected = datetime(2016, 11, 2), datetime(2016, 11, 8)
         self.assertEqual(result, expected)
 
+        result = get_since_until(time_range='yesterday : tomorrow', 
time_shift='1 day')
+        expected = datetime(2016, 11, 5), datetime(2016, 11, 7)
+        self.assertEqual(result, expected)
+
+        with self.assertRaises(ValueError):
+            get_since_until(time_range='tomorrow : yesterday')
+
     @patch('superset.utils.core.to_adhoc', mock_to_adhoc)
     def test_convert_legacy_filters_into_adhoc_where(self):
         form_data = {

Reply via email to