feat(explore): time picker enhancement (#11418)

This commit is contained in:
Yongjie Zhao 2020-12-18 10:27:21 +08:00 committed by GitHub
parent 92fdf54aa3
commit b592cc7e73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1521 additions and 91 deletions

View File

@ -24,6 +24,7 @@ chardet==3.0.4 # via aiohttp
click==7.1.2 # via apache-superset, flask, flask-appbuilder
colorama==0.4.4 # via apache-superset, flask-appbuilder
contextlib2==0.6.0.post1 # via apache-superset
convertdate==2.3.0 # via holidays
cron-descriptor==1.2.24 # via apache-superset
croniter==0.3.36 # via apache-superset
cryptography==3.2.1 # via apache-superset
@ -46,6 +47,7 @@ flask==1.1.2 # via apache-superset, flask-appbuilder, flask-babel,
geographiclib==1.50 # via geopy
geopy==2.0.0 # via apache-superset
gunicorn==20.0.4 # via apache-superset
holidays==0.10.3 # via apache-superset
humanize==3.1.0 # via apache-superset
idna==2.10 # via email-validator, yarl
importlib-metadata==2.1.1 # via -r requirements/base.in, jsonschema, kombu, markdown
@ -54,6 +56,7 @@ itsdangerous==1.1.0 # via flask, flask-wtf
jinja2==2.11.2 # via flask, flask-babel
jsonschema==3.2.0 # via flask-appbuilder
kombu==4.6.11 # via celery
korean-lunar-calendar==0.2.1 # via holidays
mako==1.1.3 # via alembic
markdown==3.3.3 # via apache-superset
markupsafe==1.1.1 # via jinja2, mako, wtforms
@ -75,20 +78,21 @@ py==1.9.0 # via retry
pyarrow==1.0.1 # via apache-superset
pycparser==2.20 # via cffi
pyjwt==1.7.1 # via flask-appbuilder, flask-jwt-extended
pyparsing==2.4.7 # via packaging
pymeeus==0.3.7 # via convertdate
pyparsing==2.4.7 # via apache-superset, packaging
pyrsistent==0.16.1 # via -r requirements/base.in, jsonschema
python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, pandas
python-dateutil==2.8.1 # via alembic, apache-superset, croniter, flask-appbuilder, holidays, pandas
python-dotenv==0.15.0 # via apache-superset
python-editor==1.0.4 # via alembic
python-geohash==0.8.5 # via apache-superset
python3-openid==3.2.0 # via flask-openid
pytz==2020.4 # via babel, celery, flask-babel, pandas
pytz==2020.4 # via babel, celery, convertdate, flask-babel, pandas
pyyaml==5.3.1 # via apache-superset, apispec
redis==3.5.3 # via apache-superset
retry==0.9.2 # via apache-superset
selenium==3.141.0 # via apache-superset
simplejson==3.17.2 # via apache-superset
six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, isodate, jsonschema, packaging, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json
six==1.15.0 # via bleach, cryptography, flask-jwt-extended, flask-talisman, holidays, isodate, jsonschema, pathlib2, polyline, prison, pyrsistent, python-dateutil, sqlalchemy-utils, wtforms-json
slackclient==2.5.0 # via apache-superset
sqlalchemy-utils==0.36.8 # via apache-superset, flask-appbuilder
sqlalchemy==1.3.20 # via alembic, apache-superset, flask-sqlalchemy, marshmallow-sqlalchemy, sqlalchemy-utils

View File

@ -30,7 +30,7 @@ combine_as_imports = true
include_trailing_comma = true
line_length = 88
known_first_party = superset
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
known_third_party =alembic,apispec,backoff,bleach,cachelib,celery,click,colorama,contextlib2,cron_descriptor,croniter,cryptography,dateutil,flask,flask_appbuilder,flask_babel,flask_caching,flask_compress,flask_login,flask_migrate,flask_sqlalchemy,flask_talisman,flask_testing,flask_wtf,freezegun,geohash,geopy,holidays,humanize,isodate,jinja2,jwt,markdown,markupsafe,marshmallow,msgpack,numpy,pandas,parameterized,parsedatetime,pathlib2,pgsanity,pkg_resources,polyline,prison,pyarrow,pyhive,pyparsing,pytest,pytz,redis,retry,selenium,setuptools,simplejson,slack,sqlalchemy,sqlalchemy_utils,sqlparse,typing_extensions,werkzeug,wtforms,wtforms_json,yaml
multi_line_output = 3
order_by_type = false

View File

@ -106,6 +106,8 @@ setup(
"sqlalchemy-utils>=0.36.6,<0.37",
"sqlparse==0.3.0", # PINNED! see https://github.com/andialbrecht/sqlparse/issues/562
"wtforms-json",
"pyparsing>=2.4.7, <3.0.0",
"holidays==0.10.3", # PINNED! https://github.com/dr-prodigy/python-holidays/issues/406
],
extras_require={
"athena": ["pyathena>=1.10.8,<1.11"],

View File

@ -138,20 +138,20 @@ describe('Time range filter', () => {
cy.visitChartByParams(JSON.stringify(formData));
cy.verifySliceSuccess({ waitAlias: '@postJson' });
cy.get('[data-test=time_range]').within(() => {
cy.get('span.label').click();
});
cy.get('#filter-popover').within(() => {
cy.get('div.ant-tabs-tabpane-active').within(() => {
cy.get('div.PopoverSection :not(.dimmed)').within(() => {
cy.get('[data-test=time-range-trigger]')
.click()
.then(() => {
cy.get('.ant-modal-footer')
.find('button')
.its('length')
.should('eq', 3);
cy.get('.ant-modal-body').within(() => {
cy.get('input[value="100 years ago"]');
cy.get('input[value="now"]');
});
cy.get('[data-test=modal-cancel-button]').click();
cy.get('[data-test=time-range-modal]').should('not.be.visible');
});
});
cy.get('#filter-popover button').contains('Ok').click();
cy.get('#filter-popover').should('not.be.visible');
});
});

View File

@ -32,14 +32,17 @@ export {
Avatar,
Button,
Card,
Col,
DatePicker,
Divider,
Dropdown,
Empty,
Input,
InputNumber,
Modal,
Popover,
Radio,
Row,
Select,
Skeleton,
Switch,

View File

@ -0,0 +1,879 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, useEffect } from 'react';
import rison from 'rison';
import moment, { Moment } from 'moment';
import {
SupersetClient,
styled,
supersetTheme,
t,
TimeRangeEndpoints,
} from '@superset-ui/core';
import {
buildTimeRangeString,
formatTimeRange,
SEPARATOR,
} from 'src/explore/dateFilterUtils';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import Button from 'src/components/Button';
import ControlHeader from 'src/explore/components/ControlHeader';
import Label from 'src/components/Label';
import Modal from 'src/common/components/Modal';
import {
Col,
DatePicker,
Divider,
Input,
InputNumber,
Radio,
Row,
} from 'src/common/components';
import Icon from 'src/components/Icon';
import { Select } from 'src/components/Select';
import {
TimeRangeFrameType,
CommonRangeType,
CalendarRangeType,
CustomRangeType,
CustomRangeDecodeType,
CustomRangeKey,
PreviousCalendarWeek,
PreviousCalendarMonth,
PreviousCalendarYear,
} from './types';
import {
COMMON_RANGE_OPTIONS,
CALENDAR_RANGE_OPTIONS,
RANGE_FRAME_OPTIONS,
SINCE_GRAIN_OPTIONS,
UNTIL_GRAIN_OPTIONS,
SINCE_MODE_OPTIONS,
UNTIL_MODE_OPTIONS,
} from './constants';
const MOMENT_FORMAT = 'YYYY-MM-DD[T]HH:mm:ss';
const DEFAULT_SINCE = moment()
.utc()
.startOf('day')
.subtract(7, 'days')
.format(MOMENT_FORMAT);
const DEFAULT_UNTIL = moment().utc().startOf('day').format(MOMENT_FORMAT);
/**
* RegExp to test a string for a full ISO 8601 Date
* Does not do any sort of date validation, only checks if the string is according to the ISO 8601 spec.
* YYYY-MM-DDThh:mm:ss
* YYYY-MM-DDThh:mm:ssTZD
* YYYY-MM-DDThh:mm:ss.sTZD
* @see: https://www.w3.org/TR/NOTE-datetime
*/
const iso8601 = String.raw`\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:(?:[+-]\d\d:\d\d)|Z)?`;
const datetimeConstant = String.raw`TODAY|NOW`;
const grainValue = String.raw`[+-]?[1-9][0-9]*`;
const grain = String.raw`YEAR|QUARTER|MONTH|WEEK|DAY|HOUR|MINUTE|SECOND`;
const CUSTOM_RANGE_EXPRESSION = RegExp(
String.raw`^DATEADD\(DATETIME\("(${iso8601}|${datetimeConstant})"\),\s(${grainValue}),\s(${grain})\)$`,
'i',
);
export const ISO8601_AND_CONSTANT = RegExp(
String.raw`^${iso8601}$|^${datetimeConstant}$`,
'i',
);
const DATETIME_CONSTANT = ['now', 'today'];
const defaultCustomRange: CustomRangeType = {
sinceDatetime: DEFAULT_SINCE,
sinceMode: 'relative',
sinceGrain: 'day',
sinceGrainValue: -7,
untilDatetime: DEFAULT_UNTIL,
untilMode: 'specific',
untilGrain: 'day',
untilGrainValue: 7,
anchorMode: 'now',
anchorValue: 'now',
};
const SPECIFIC_MODE = ['specific', 'today', 'now'];
const COMMON_RANGE_OPTIONS_SET = new Set(
COMMON_RANGE_OPTIONS.map(({ value }) => value),
);
const CALENDAR_RANGE_OPTIONS_SET = new Set(
CALENDAR_RANGE_OPTIONS.map(({ value }) => value),
);
const commonRangeSet: Set<CommonRangeType> = new Set([
'Last day',
'Last week',
'Last month',
'Last quarter',
'Last year',
]);
const CalendarRangeSet: Set<CalendarRangeType> = new Set([
PreviousCalendarWeek,
PreviousCalendarMonth,
PreviousCalendarYear,
]);
const customTimeRangeDecode = (timeRange: string): CustomRangeDecodeType => {
const splitDateRange = timeRange.split(SEPARATOR);
if (splitDateRange.length === 2) {
const [since, until] = splitDateRange;
// specific : specific
if (ISO8601_AND_CONSTANT.test(since) && ISO8601_AND_CONSTANT.test(until)) {
const sinceMode = DATETIME_CONSTANT.includes(since) ? since : 'specific';
const untilMode = DATETIME_CONSTANT.includes(until) ? until : 'specific';
return {
customRange: {
...defaultCustomRange,
sinceDatetime: since,
untilDatetime: until,
sinceMode,
untilMode,
},
matchedFlag: true,
};
}
// relative : specific
const sinceCapturedGroup = since.match(CUSTOM_RANGE_EXPRESSION);
if (
sinceCapturedGroup &&
ISO8601_AND_CONSTANT.test(until) &&
since.includes(until)
) {
const [dttm, grainValue, grain] = sinceCapturedGroup.slice(1);
const untilMode = DATETIME_CONSTANT.includes(until) ? until : 'specific';
return {
customRange: {
...defaultCustomRange,
sinceGrain: grain,
sinceGrainValue: parseInt(grainValue, 10),
untilDatetime: dttm,
sinceMode: 'relative',
untilMode,
},
matchedFlag: true,
};
}
// specific : relative
const untilCapturedGroup = until.match(CUSTOM_RANGE_EXPRESSION);
if (
ISO8601_AND_CONSTANT.test(since) &&
untilCapturedGroup &&
until.includes(since)
) {
const [dttm, grainValue, grain] = [...untilCapturedGroup.slice(1)];
const sinceMode = DATETIME_CONSTANT.includes(since) ? since : 'specific';
return {
customRange: {
...defaultCustomRange,
untilGrain: grain,
untilGrainValue: parseInt(grainValue, 10),
sinceDatetime: dttm,
untilMode: 'relative',
sinceMode,
},
matchedFlag: true,
};
}
// relative : relative
if (sinceCapturedGroup && untilCapturedGroup) {
const [sinceDttm, sinceGrainValue, sinceGrain] = [
...sinceCapturedGroup.slice(1),
];
const [untileDttm, untilGrainValue, untilGrain] = [
...untilCapturedGroup.slice(1),
];
if (sinceDttm === untileDttm) {
return {
customRange: {
...defaultCustomRange,
sinceGrain,
sinceGrainValue: parseInt(sinceGrainValue, 10),
untilGrain,
untilGrainValue: parseInt(untilGrainValue, 10),
anchorValue: sinceDttm,
sinceMode: 'relative',
untilMode: 'relative',
anchorMode: sinceDttm === 'now' ? 'now' : 'specific',
},
matchedFlag: true,
};
}
}
}
return {
customRange: defaultCustomRange,
matchedFlag: false,
};
};
const customTimeRangeEncode = (customRange: CustomRangeType): string => {
const {
sinceDatetime,
sinceMode,
sinceGrain,
sinceGrainValue,
untilDatetime,
untilMode,
untilGrain,
untilGrainValue,
anchorValue,
} = { ...customRange };
// specific : specific
if (SPECIFIC_MODE.includes(sinceMode) && SPECIFIC_MODE.includes(untilMode)) {
const since = sinceMode === 'specific' ? sinceDatetime : sinceMode;
const until = untilMode === 'specific' ? untilDatetime : untilMode;
return `${since} : ${until}`;
}
// specific : relative
if (SPECIFIC_MODE.includes(sinceMode) && untilMode === 'relative') {
const since = sinceMode === 'specific' ? sinceDatetime : sinceMode;
const until = `DATEADD(DATETIME("${since}"), ${untilGrainValue}, ${untilGrain})`;
return `${since} : ${until}`;
}
// relative : specific
if (sinceMode === 'relative' && SPECIFIC_MODE.includes(untilMode)) {
const until = untilMode === 'specific' ? untilDatetime : untilMode;
const since = `DATEADD(DATETIME("${until}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line
return `${since} : ${until}`;
}
// relative : relative
const since = `DATEADD(DATETIME("${anchorValue}"), ${-Math.abs(sinceGrainValue)}, ${sinceGrain})`; // eslint-disable-line
const until = `DATEADD(DATETIME("${anchorValue}"), ${untilGrainValue}, ${untilGrain})`;
return `${since} : ${until}`;
};
const guessTimeRangeFrame = (timeRange: string): TimeRangeFrameType => {
if (COMMON_RANGE_OPTIONS_SET.has(timeRange)) {
return 'Common';
}
if (CALENDAR_RANGE_OPTIONS_SET.has(timeRange)) {
return 'Calendar';
}
if (timeRange === 'No filter') {
return 'No Filter';
}
if (customTimeRangeDecode(timeRange).matchedFlag) {
return 'Custom';
}
return 'Advanced';
};
const dttmToMoment = (dttm: string): Moment => {
if (dttm === 'now') {
return moment().utc().startOf('second');
}
if (dttm === 'today') {
return moment().utc().startOf('day');
}
return moment(dttm);
};
const fetchTimeRange = async (
timeRange: string,
endpoints?: TimeRangeEndpoints,
) => {
const query = rison.encode(timeRange);
const endpoint = `/api/v1/time_range/?q=${query}`;
try {
const response = await SupersetClient.get({ endpoint });
const timeRangeString = buildTimeRangeString(
response?.json?.result?.since || '',
response?.json?.result?.until || '',
);
return {
value: formatTimeRange(timeRangeString, endpoints),
};
} catch (response) {
const clientError = await getClientErrorObject(response);
return {
error: clientError.message || clientError.error,
};
}
};
const StyledModalContainer = styled.div`
.ant-row {
margin-top: 8px;
}
.ant-input-number {
width: 100%;
}
.ant-picker {
padding: 4px 17px 4px;
border-radius: 4px;
width: 100%;
}
.ant-divider-horizontal {
margin: 16px 0;
}
.control-label {
font-size: 11px;
font-weight: 500;
color: #b2b2b2;
line-height: 16px;
text-transform: uppercase;
margin: 8px 0;
}
.vertical-radio {
display: block;
height: 40px;
line-height: 40px;
}
.section-title {
font-style: normal;
font-weight: 500;
font-size: 15px;
line-height: 24px;
margin-bottom: 8px;
}
`;
const StyledValidateBtn = styled.span`
.validate-btn {
float: left;
}
`;
const IconWrapper = styled.span`
svg {
margin-right: ${({ theme }) => 2 * theme.gridUnit}px;
vertical-align: middle;
display: inline-block;
}
.text {
vertical-align: middle;
display: inline-block;
}
.error {
color: ${({ theme }) => theme.colors.error.base};
}
`;
interface DateFilterLabelProps {
name: string;
onChange: (timeRange: string) => void;
value?: string;
endpoints?: TimeRangeEndpoints;
}
export default function DateFilterControl(props: DateFilterLabelProps) {
const { value = 'Last week', endpoints, onChange } = props;
const [actualTimeRange, setActualTimeRange] = useState<string>(value);
// State used for Modal
const [show, setShow] = useState<boolean>(false);
const [timeRangeFrame, setTimeRangeFrame] = useState<TimeRangeFrameType>(
guessTimeRangeFrame(value),
);
const [commonRange, setCommonRange] = useState<CommonRangeType>(
getDefaultOrCommonRange(value),
);
const [calendarRange, setCalendarRange] = useState<CalendarRangeType>(
getDefaultOrCalendarRange(value),
);
const [customRange, setCustomRange] = useState<CustomRangeType>(
customTimeRangeDecode(value).customRange,
);
const [advancedRange, setAdvancedRange] = useState<string>(
getAdvancedRange(value),
);
const [validTimeRange, setValidTimeRange] = useState<boolean>(false);
const [evalTimeRange, setEvalTimeRange] = useState<string>(value);
useEffect(() => {
fetchTimeRange(value, endpoints).then(({ value, error }) => {
if (error) {
setEvalTimeRange(error || '');
setValidTimeRange(false);
} else {
setActualTimeRange(value || '');
setValidTimeRange(true);
}
});
}, [value]);
useEffect(() => {
const value = getCurrentValue();
fetchTimeRange(value, endpoints).then(({ value, error }) => {
if (error) {
setEvalTimeRange(error || '');
setValidTimeRange(false);
} else {
setEvalTimeRange(value || '');
setValidTimeRange(true);
}
});
}, [timeRangeFrame, commonRange, calendarRange, customRange]);
function getCurrentValue(): string {
// get current time_range string
let value = 'Last week';
if (timeRangeFrame === 'Common') {
value = commonRange;
}
if (timeRangeFrame === 'Calendar') {
value = calendarRange;
}
if (timeRangeFrame === 'Custom') {
value = customTimeRangeEncode(customRange);
}
if (timeRangeFrame === 'Advanced') {
value = advancedRange;
}
if (timeRangeFrame === 'No Filter') {
value = 'No filter';
}
return value;
}
function getDefaultOrCommonRange(value: any): CommonRangeType {
return commonRangeSet.has(value) ? value : 'Last week';
}
function getDefaultOrCalendarRange(value: any): CalendarRangeType {
return CalendarRangeSet.has(value) ? value : PreviousCalendarWeek;
}
function getAdvancedRange(value: string): string {
if (value.includes(SEPARATOR)) {
return value;
}
if (value.startsWith('Last')) {
return [value, ''].join(SEPARATOR);
}
if (value.startsWith('Next')) {
return ['', value].join(SEPARATOR);
}
return SEPARATOR;
}
function onAdvancedRangeChange(control: 'since' | 'until', value: string) {
setValidTimeRange(false);
setEvalTimeRange(t('Need to verify the time range.'));
const [since, until] = advancedRange.split(SEPARATOR);
if (control === 'since') {
setAdvancedRange(`${value}${SEPARATOR}${until}`);
} else {
setAdvancedRange(`${since}${SEPARATOR}${value}`);
}
}
function onCustomRangeChange(
control: CustomRangeKey,
value: string | number,
) {
setCustomRange({
...customRange,
[control]: value,
});
}
function onCustomRangeChangeAnchorMode(option: any) {
const radioValue = option.target.value;
if (radioValue === 'now') {
setCustomRange({
...customRange,
anchorValue: 'now',
anchorMode: radioValue,
});
} else {
setCustomRange({
...customRange,
anchorValue: DEFAULT_UNTIL,
anchorMode: radioValue,
});
}
}
function showValidateBtn(): boolean {
return timeRangeFrame === 'Advanced';
}
function resetState(value: string) {
setTimeRangeFrame(guessTimeRangeFrame(value));
setCommonRange(getDefaultOrCommonRange(value));
setCalendarRange(getDefaultOrCalendarRange(value));
setCustomRange(customTimeRangeDecode(value).customRange);
setAdvancedRange(getAdvancedRange(value));
setShow(false);
}
function onSave() {
const currentValue = getCurrentValue();
onChange(currentValue);
resetState(currentValue);
}
function onHide() {
resetState(value);
}
function onValidate() {
const value = getCurrentValue();
fetchTimeRange(value, endpoints).then(({ value, error }) => {
if (error) {
setEvalTimeRange(error || '');
setValidTimeRange(false);
} else {
setEvalTimeRange(value || '');
setValidTimeRange(true);
}
});
}
function renderCommon() {
const commonRangeValue =
COMMON_RANGE_OPTIONS.find(({ value }) => value === commonRange)?.value ||
'Last week';
return (
<>
<div className="section-title">
{t('Configure Time Range: Last...')}
</div>
<Radio.Group
value={commonRangeValue}
onChange={(e: any) => setCommonRange(e.target.value)}
>
{COMMON_RANGE_OPTIONS.map(({ value, label }) => (
<Radio key={value} value={value} className="vertical-radio">
{label}
</Radio>
))}
</Radio.Group>
</>
);
}
function renderCalendar() {
const currentValue =
CALENDAR_RANGE_OPTIONS.find(({ value }) => value === calendarRange)
?.value || PreviousCalendarWeek;
return (
<>
<div className="section-title">
{t('Configure Time Range: Previous...')}
</div>
<Radio.Group
value={currentValue}
onChange={(e: any) => setCalendarRange(e.target.value)}
>
{CALENDAR_RANGE_OPTIONS.map(({ value, label }) => (
<Radio key={value} value={value} className="vertical-radio">
{label}
</Radio>
))}
</Radio.Group>
</>
);
}
function renderAdvanced() {
const [since, until] = advancedRange.split(SEPARATOR);
return (
<>
<div className="section-title">
{t('Configure Advanced Time Range')}
</div>
<div className="control-label">{t('START')}</div>
<Input
key="since"
value={since}
onChange={e => onAdvancedRangeChange('since', e.target.value)}
/>
<div className="control-label">{t('END')}</div>
<Input
key="until"
value={until}
onChange={e => onAdvancedRangeChange('until', e.target.value)}
/>
</>
);
}
function renderCustom() {
const {
sinceDatetime,
sinceMode,
sinceGrain,
sinceGrainValue,
untilDatetime,
untilMode,
untilGrain,
untilGrainValue,
anchorValue,
anchorMode,
} = { ...customRange };
return (
<>
<div className="section-title">{t('Configure Custom Time Range')}</div>
<Row gutter={8}>
<Col span={12}>
<div className="control-label">{t('START')}</div>
<Select
options={SINCE_MODE_OPTIONS}
value={SINCE_MODE_OPTIONS.filter(
option => option.value === sinceMode,
)}
onChange={(option: any) =>
onCustomRangeChange('sinceMode', option.value)
}
/>
{sinceMode === 'specific' && (
<Row>
<DatePicker
showTime
value={dttmToMoment(sinceDatetime)}
onChange={(datetime: Moment) =>
onCustomRangeChange(
'sinceDatetime',
datetime.format(MOMENT_FORMAT),
)
}
allowClear={false}
/>
</Row>
)}
{sinceMode === 'relative' && (
<Row gutter={8}>
<Col span={10}>
{/* Make sure sinceGrainValue looks like a positive integer */}
<InputNumber
placeholder={t('Relative quantity')}
value={Math.abs(sinceGrainValue)}
min={1}
defaultValue={1}
onStep={value =>
onCustomRangeChange('sinceGrainValue', value || 1)
}
/>
</Col>
<Col span={14}>
<Select
options={SINCE_GRAIN_OPTIONS}
value={SINCE_GRAIN_OPTIONS.filter(
option => option.value === sinceGrain,
)}
onChange={(option: any) =>
onCustomRangeChange('sinceGrain', option.value)
}
/>
</Col>
</Row>
)}
</Col>
<Col span={12}>
<div className="control-label">{t('END')}</div>
<Select
options={UNTIL_MODE_OPTIONS}
value={UNTIL_MODE_OPTIONS.filter(
option => option.value === untilMode,
)}
onChange={(option: any) =>
onCustomRangeChange('untilMode', option.value)
}
/>
{untilMode === 'specific' && (
<Row>
<DatePicker
showTime
value={dttmToMoment(untilDatetime)}
onChange={(datetime: Moment) =>
onCustomRangeChange(
'untilDatetime',
datetime.format(MOMENT_FORMAT),
)
}
allowClear={false}
/>
</Row>
)}
{untilMode === 'relative' && (
<Row gutter={8}>
<Col span={10}>
<InputNumber
placeholder={t('Relative quantity')}
value={untilGrainValue}
min={1}
defaultValue={1}
onStep={value =>
onCustomRangeChange('untilGrainValue', value || 1)
}
/>
</Col>
<Col span={14}>
<Select
options={UNTIL_GRAIN_OPTIONS}
value={UNTIL_GRAIN_OPTIONS.filter(
option => option.value === untilGrain,
)}
onChange={(option: any) =>
onCustomRangeChange('untilGrain', option.value)
}
/>
</Col>
</Row>
)}
</Col>
</Row>
{sinceMode === 'relative' && untilMode === 'relative' && (
<>
<div className="control-label">{t('ANCHOR RELATIVE TO')}</div>
<Row align="middle">
<Col>
<Radio.Group
onChange={onCustomRangeChangeAnchorMode}
defaultValue="now"
value={anchorMode}
>
<Radio key="now" value="now">
{t('NOW')}
</Radio>
<Radio key="specific" value="specific">
{t('Date/Time')}
</Radio>
</Radio.Group>
</Col>
{anchorMode !== 'now' && (
<Col>
<DatePicker
showTime
value={dttmToMoment(anchorValue)}
onChange={(datetime: Moment) =>
onCustomRangeChange(
'anchorValue',
datetime.format(MOMENT_FORMAT),
)
}
allowClear={false}
/>
</Col>
)}
</Row>
</>
)}
</>
);
}
return (
<>
<ControlHeader {...props} />
<Label
className="pointer"
data-test="time-range-trigger"
onClick={() => setShow(true)}
>
{actualTimeRange}
</Label>
<Modal
name="time-range" // data-test=time-range-modal
title={
<IconWrapper>
<Icon name="edit-alt" />
<span className="text">{t('Edit Time Range')}</span>
</IconWrapper>
}
show={show}
onHide={onHide}
footer={[
<Button
buttonStyle="secondary"
cta
key="cancel"
onClick={onHide}
data-test="modal-cancel-button"
>
{t('CANCEL')}
</Button>,
<Button
buttonStyle="primary"
cta
disabled={!validTimeRange}
key="apply"
onClick={onSave}
>
{t('APPLY')}
</Button>,
showValidateBtn() && (
<StyledValidateBtn key="validate">
<Button
buttonStyle="tertiary"
cta
className="validate-btn"
onClick={onValidate}
>
{t('Validate')}
</Button>
</StyledValidateBtn>
),
]}
>
<StyledModalContainer>
<div className="control-label">{t('RANGE TYPE')}</div>
<Select
options={RANGE_FRAME_OPTIONS}
value={RANGE_FRAME_OPTIONS.filter(
({ value }) => value === timeRangeFrame,
)}
onChange={(_: any) => setTimeRangeFrame(_.value)}
/>
{timeRangeFrame !== 'No Filter' && <Divider />}
{timeRangeFrame === 'Common' && renderCommon()}
{timeRangeFrame === 'Calendar' && renderCalendar()}
{timeRangeFrame === 'Advanced' && renderAdvanced()}
{timeRangeFrame === 'Custom' && renderCustom()}
<Divider />
<div>
<div className="section-title">{t('Actual Time Range')}</div>
{validTimeRange && <div>{evalTimeRange}</div>}
{!validTimeRange && (
<IconWrapper className="warning">
<Icon
name="error-solid-small"
color={supersetTheme.colors.error.base}
/>
<span className="text error">{evalTimeRange}</span>
</IconWrapper>
)}
</div>
</StyledModalContainer>
</Modal>
</>
);
}

View File

@ -0,0 +1,80 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import {
SelectOptionType,
PreviousCalendarWeek,
PreviousCalendarMonth,
PreviousCalendarYear,
} from './types';
export const RANGE_FRAME_OPTIONS: SelectOptionType[] = [
{ value: 'Common', label: t('Last') },
{ value: 'Calendar', label: t('Previous') },
{ value: 'Custom', label: t('Custom') },
{ value: 'Advanced', label: t('Advanced') },
{ value: 'No Filter', label: t('No Filter') },
];
export const COMMON_RANGE_OPTIONS: SelectOptionType[] = [
{ value: 'Last day', label: t('Last day') },
{ value: 'Last week', label: t('Last week') },
{ value: 'Last month', label: t('Last month') },
{ value: 'Last quarter', label: t('Last quarter') },
{ value: 'Last year', label: t('Last year') },
];
export const CALENDAR_RANGE_OPTIONS: SelectOptionType[] = [
{ value: PreviousCalendarWeek, label: t('Previous Calendar week') },
{ value: PreviousCalendarMonth, label: t('Previous Calendar month') },
{ value: PreviousCalendarYear, label: t('Previous Calendar year') },
];
const GRAIN_OPTIONS = [
{ value: 'second', label: (rel: string) => `${t('Seconds')} ${rel}` },
{ value: 'minute', label: (rel: string) => `${t('Minutes')} ${rel}` },
{ value: 'hour', label: (rel: string) => `${t('Hours')} ${rel}` },
{ value: 'day', label: (rel: string) => `${t('Days')} ${rel}` },
{ value: 'week', label: (rel: string) => `${t('Weeks')} ${rel}` },
{ value: 'month', label: (rel: string) => `${t('Months')} ${rel}` },
{ value: 'year', label: (rel: string) => `${t('Years')} ${rel}` },
];
export const SINCE_GRAIN_OPTIONS: SelectOptionType[] = GRAIN_OPTIONS.map(
item => ({
value: item.value,
label: item.label(t('Before')),
}),
);
export const UNTIL_GRAIN_OPTIONS: SelectOptionType[] = GRAIN_OPTIONS.map(
item => ({
value: item.value,
label: item.label(t('After')),
}),
);
export const SINCE_MODE_OPTIONS: SelectOptionType[] = [
{ value: 'specific', label: t('Specific Date/Time') },
{ value: 'relative', label: t('Relative Date/Time') },
{ value: 'now', label: t('Now') },
{ value: 'today', label: t('Midnight') },
];
export const UNTIL_MODE_OPTIONS: SelectOptionType[] = SINCE_MODE_OPTIONS.slice();

View File

@ -0,0 +1,86 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export type SelectOptionType = {
value: string;
label: string;
};
export type TimeRangeFrameType =
| 'Common'
| 'Calendar'
| 'Custom'
| 'Advanced'
| 'No Filter';
export type DateTimeGrainType =
| 'second'
| 'minite'
| 'hour'
| 'day'
| 'week'
| 'month'
| 'year';
export type CustomRangeKey =
| 'sinceMode'
| 'sinceDatetime'
| 'sinceGrain'
| 'sinceGrainValue'
| 'untilMode'
| 'untilDatetime'
| 'untilGrain'
| 'untilGrainValue'
| 'anchorMode'
| 'anchorValue';
export type CustomRangeType = {
sinceMode: string;
sinceDatetime: string;
sinceGrain: string;
sinceGrainValue: number;
untilMode: string;
untilDatetime: string;
untilGrain: string;
untilGrainValue: number;
anchorMode: 'now' | 'specific';
anchorValue: string;
};
export type CustomRangeDecodeType = {
customRange: CustomRangeType;
matchedFlag: boolean;
};
export type CommonRangeType =
| 'Last day'
| 'Last week'
| 'Last month'
| 'Last quarter'
| 'Last year';
export const PreviousCalendarWeek =
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, WEEK), WEEK) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, WEEK), WEEK)';
export const PreviousCalendarMonth =
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, MONTH), MONTH)';
export const PreviousCalendarYear =
'DATETRUNC(DATEADD(DATETIME("TODAY"), -1, YEAR), YEAR) : LASTDAY(DATEADD(DATETIME("TODAY"), -1, YEAR), YEAR)';
export type CalendarRangeType =
| typeof PreviousCalendarWeek
| typeof PreviousCalendarMonth
| typeof PreviousCalendarYear;

View File

@ -24,7 +24,7 @@ import ColorMapControl from './ColorMapControl';
import ColorPickerControl from './ColorPickerControl';
import ColorSchemeControl from './ColorSchemeControl';
import DatasourceControl from './DatasourceControl';
import DateFilterControl from './DateFilterControl';
import DateFilterControl from './DateFilterControl/DateFilterControl';
import FixedOrMetricControl from './FixedOrMetricControl';
import HiddenControl from './HiddenControl';
import SelectAsyncControl from './SelectAsyncControl';

View File

@ -18,7 +18,7 @@
*/
import { TimeRangeEndpoints } from '@superset-ui/core';
const SEPARATOR = ' : ';
export const SEPARATOR = ' : ';
export const buildTimeRangeString = (since: string, until: string): string =>
`${since}${SEPARATOR}${until}`;

View File

@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
"""Utility functions used across Superset"""
import calendar
import decimal
import errno
import functools
@ -75,6 +76,18 @@ from flask import current_app, flash, g, Markup, render_template
from flask_appbuilder import SQLA
from flask_appbuilder.security.sqla.models import Role, User
from flask_babel import gettext as __, lazy_gettext as _
from holidays import CountryHoliday
from pyparsing import (
CaselessKeyword,
Forward,
Group,
Optional as ppOptional,
ParseException,
ParseResults,
pyparsing_common,
quotedString,
Suppress,
)
from sqlalchemy import event, exc, select, Text
from sqlalchemy.dialects.mysql import MEDIUMTEXT
from sqlalchemy.engine import Connection, Engine
@ -447,9 +460,13 @@ def parse_human_datetime(human_readable: str) -> datetime:
>>> date.today() - timedelta(1) == parse_human_datetime('yesterday').date()
True
>>> year_ago_1 = parse_human_datetime('one year ago').date()
>>> year_ago_2 = (datetime.now() - relativedelta(years=1) ).date()
>>> year_ago_2 = (datetime.now() - relativedelta(years=1)).date()
>>> year_ago_1 == year_ago_2
True
>>> year_after_1 = parse_human_datetime('2 years after').date()
>>> year_after_2 = (datetime.now() + relativedelta(years=2)).date()
>>> year_after_1 == year_after_2
True
"""
try:
dttm = parse(human_readable)
@ -499,28 +516,26 @@ class DashboardEncoder(json.JSONEncoder):
return json.JSONEncoder(sort_keys=True).default(o)
def parse_human_timedelta(human_readable: Optional[str]) -> timedelta:
def parse_human_timedelta(
human_readable: Optional[str], source_time: Optional[datetime] = None,
) -> timedelta:
"""
Returns ``datetime.datetime`` from natural language time deltas
Returns ``datetime.timedelta`` from natural language time deltas
>>> parse_human_datetime('now') <= datetime.now()
>>> parse_human_timedelta('1 day') == timedelta(days=1)
True
"""
cal = parsedatetime.Calendar()
dttm = dttm_from_timetuple(datetime.now().timetuple())
date_ = cal.parse(human_readable or "", dttm)[0]
date_ = datetime(
date_.tm_year,
date_.tm_mon,
date_.tm_mday,
date_.tm_hour,
date_.tm_min,
date_.tm_sec,
source_dttm = dttm_from_timetuple(
source_time.timetuple() if source_time else datetime.now().timetuple()
)
return date_ - dttm
modified_dttm = dttm_from_timetuple(cal.parse(human_readable or "", source_dttm)[0])
return modified_dttm - source_dttm
def parse_past_timedelta(delta_str: str) -> timedelta:
def parse_past_timedelta(
delta_str: str, source_time: Optional[datetime] = None
) -> timedelta:
"""
Takes a delta like '1 year' and finds the timedelta for that period in
the past, then represents that past timedelta in positive terms.
@ -530,7 +545,7 @@ def parse_past_timedelta(delta_str: str) -> timedelta:
or datetime.timedelta(365).
"""
return -parse_human_timedelta(
delta_str if delta_str.startswith("-") else f"-{delta_str}"
delta_str if delta_str.startswith("-") else f"-{delta_str}", source_time,
)
@ -1231,7 +1246,205 @@ def ensure_path_exists(path: str) -> None:
raise
def get_since_until( # pylint: disable=too-many-arguments
class EvalText: # pylint: disable=too-few-public-methods
def __init__(self, tokens: ParseResults) -> None:
self.value = tokens[0]
def eval(self) -> str:
# strip quotes
return self.value[1:-1]
class EvalDateTimeFunc: # pylint: disable=too-few-public-methods
def __init__(self, tokens: ParseResults) -> None:
self.value = tokens[1]
def eval(self) -> datetime:
return parse_human_datetime(self.value.eval())
class EvalDateAddFunc: # pylint: disable=too-few-public-methods
def __init__(self, tokens: ParseResults) -> None:
self.value = tokens[1]
def eval(self) -> datetime:
dttm_expression, delta, unit = self.value
dttm = dttm_expression.eval()
if unit.lower() == "quarter":
delta = delta * 3
unit = "month"
return dttm + parse_human_timedelta(f"{delta} {unit}s", dttm)
class EvalDateTruncFunc: # pylint: disable=too-few-public-methods
def __init__(self, tokens: ParseResults) -> None:
self.value = tokens[1]
def eval(self) -> datetime:
dttm_expression, unit = self.value
dttm = dttm_expression.eval()
if unit == "year":
dttm = dttm.replace(
month=1, day=1, hour=0, minute=0, second=0, microsecond=0
)
elif unit == "month":
dttm = dttm.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
elif unit == "week":
dttm = dttm - relativedelta(days=dttm.weekday())
dttm = dttm.replace(hour=0, minute=0, second=0, microsecond=0)
elif unit == "day":
dttm = dttm.replace(hour=0, minute=0, second=0, microsecond=0)
elif unit == "hour":
dttm = dttm.replace(minute=0, second=0, microsecond=0)
elif unit == "minute":
dttm = dttm.replace(second=0, microsecond=0)
else:
dttm = dttm.replace(microsecond=0)
return dttm
class EvalLastDayFunc: # pylint: disable=too-few-public-methods
def __init__(self, tokens: ParseResults) -> None:
self.value = tokens[1]
def eval(self) -> datetime:
dttm_expression, unit = self.value
dttm = dttm_expression.eval()
if unit == "year":
return dttm.replace(
month=12, day=31, hour=0, minute=0, second=0, microsecond=0
)
if unit == "month":
return dttm.replace(
day=calendar.monthrange(dttm.year, dttm.month)[1],
hour=0,
minute=0,
second=0,
microsecond=0,
)
# unit == "week":
mon = dttm - relativedelta(days=dttm.weekday())
mon = mon.replace(hour=0, minute=0, second=0, microsecond=0)
return mon + relativedelta(days=6)
class EvalHolidayFunc: # pylint: disable=too-few-public-methods
def __init__(self, tokens: ParseResults) -> None:
self.value = tokens[1]
def eval(self) -> datetime:
holiday = self.value[0].eval()
dttm, country = [None, None]
if len(self.value) >= 2:
dttm = self.value[1].eval()
if len(self.value) == 3:
country = self.value[2]
holiday_year = dttm.year if dttm else parse_human_datetime("today").year
country = country.eval() if country else "US"
holiday_lookup = CountryHoliday(country, years=[holiday_year], observed=False)
searched_result = holiday_lookup.get_named(holiday)
if len(searched_result) == 1:
return dttm_from_timetuple(searched_result[0].timetuple())
raise ValueError(_("Unable to find such a holiday: [{}]").format(holiday))
@memoized()
def datetime_parser() -> ParseResults: # pylint: disable=too-many-locals
( # pylint: disable=invalid-name
DATETIME,
DATEADD,
DATETRUNC,
LASTDAY,
HOLIDAY,
YEAR,
QUARTER,
MONTH,
WEEK,
DAY,
HOUR,
MINUTE,
SECOND,
) = map(
CaselessKeyword,
"datetime dateadd datetrunc lastday holiday "
"year quarter month week day hour minute second".split(),
)
lparen, rparen, comma = map(Suppress, "(),")
int_operand = pyparsing_common.signed_integer().setName("int_operand")
text_operand = quotedString.setName("text_operand").setParseAction(EvalText)
# allow expression to be used recursively
datetime_func = Forward().setName("datetime")
dateadd_func = Forward().setName("dateadd")
datetrunc_func = Forward().setName("datetrunc")
lastday_func = Forward().setName("lastday")
holiday_func = Forward().setName("holiday")
date_expr = (
datetime_func | dateadd_func | datetrunc_func | lastday_func | holiday_func
)
datetime_func <<= (DATETIME + lparen + text_operand + rparen).setParseAction(
EvalDateTimeFunc
)
dateadd_func <<= (
DATEADD
+ lparen
+ Group(
date_expr
+ comma
+ int_operand
+ comma
+ (YEAR | QUARTER | MONTH | WEEK | DAY | HOUR | MINUTE | SECOND)
+ ppOptional(comma)
)
+ rparen
).setParseAction(EvalDateAddFunc)
datetrunc_func <<= (
DATETRUNC
+ lparen
+ Group(
date_expr
+ comma
+ (YEAR | MONTH | WEEK | DAY | HOUR | MINUTE | SECOND)
+ ppOptional(comma)
)
+ rparen
).setParseAction(EvalDateTruncFunc)
lastday_func <<= (
LASTDAY
+ lparen
+ Group(date_expr + comma + (YEAR | MONTH | WEEK) + ppOptional(comma))
+ rparen
).setParseAction(EvalLastDayFunc)
holiday_func <<= (
HOLIDAY
+ lparen
+ Group(
text_operand
+ ppOptional(comma)
+ ppOptional(date_expr)
+ ppOptional(comma)
+ ppOptional(text_operand)
+ ppOptional(comma)
)
+ rparen
).setParseAction(EvalHolidayFunc)
return date_expr
def datetime_eval(datetime_expression: Optional[str] = None) -> Optional[datetime]:
if datetime_expression:
try:
return datetime_parser().parseString(datetime_expression)[0].eval()
except ParseException as error:
raise ValueError(error)
return None
# pylint: disable=too-many-arguments, too-many-locals, too-many-branches
def get_since_until(
time_range: Optional[str] = None,
since: Optional[str] = None,
until: Optional[str] = None,
@ -1264,74 +1477,79 @@ def get_since_until( # pylint: disable=too-many-arguments
"""
separator = " : "
relative_start = parse_human_datetime( # type: ignore
relative_start if relative_start else "today"
)
relative_end = parse_human_datetime( # type: ignore
relative_end if relative_end else "today"
)
common_time_frames = {
"Last day": (
relative_start - relativedelta(days=1), # type: ignore
relative_end,
),
"Last week": (
relative_start - relativedelta(weeks=1), # type: ignore
relative_end,
),
"Last month": (
relative_start - relativedelta(months=1), # type: ignore
relative_end,
),
"Last quarter": (
relative_start - relativedelta(months=3), # type: ignore
relative_end,
),
"Last year": (
relative_start - relativedelta(years=1), # type: ignore
relative_end,
),
}
_relative_start = relative_start if relative_start else "today"
_relative_end = relative_end if relative_end else "today"
if time_range:
if separator in time_range:
since, until = time_range.split(separator, 1)
if since and since not in common_time_frames:
since = add_ago_to_since(since)
since = parse_human_datetime(since) if since else None # type: ignore
until = parse_human_datetime(until) if until else None # type: ignore
elif time_range in common_time_frames:
since, until = common_time_frames[time_range]
elif time_range == "No filter":
since = until = None
else:
rel, num, grain = time_range.split()
if rel == "Last":
since = relative_start - relativedelta( # type: ignore
**{grain: int(num)} # type: ignore
)
until = relative_end
else: # rel == 'Next'
since = relative_start
until = relative_end + relativedelta( # type: ignore
**{grain: int(num)} # type: ignore
)
if time_range == "No filter":
return None, None
if time_range and time_range.startswith("Last") and separator not in time_range:
time_range = time_range + separator + _relative_end
if time_range and time_range.startswith("Next") and separator not in time_range:
time_range = _relative_start + separator + time_range
if time_range and separator in time_range:
time_range_lookup = [
(
r"^last\s+(day|week|month|quarter|year)$",
lambda unit: f"DATEADD(DATETIME('{_relative_start}'), -1, {unit})",
),
(
r"^last\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s$",
lambda delta, unit: f"DATEADD(DATETIME('{_relative_start}'), -{int(delta)}, {unit})", # pylint: disable=line-too-long
),
(
r"^next\s+([0-9]+)\s+(second|minute|hour|day|week|month|year)s$",
lambda delta, unit: f"DATEADD(DATETIME('{_relative_end}'), {int(delta)}, {unit})", # pylint: disable=line-too-long
),
(
r"^(DATETIME.*|DATEADD.*|DATETRUNC.*|LASTDAY.*|HOLIDAY.*)$",
lambda text: text,
),
]
since_and_until_partition = [_.strip() for _ in time_range.split(separator, 1)]
since_and_until: List[Optional[str]] = []
for part in since_and_until_partition:
if not part:
# if since or until is "", set as None
since_and_until.append(None)
continue
# Is it possible to match to time_range_lookup
matched = False
for pattern, fn in time_range_lookup:
result = re.search(pattern, part, re.IGNORECASE)
if result:
matched = True
# converted matched time_range to "formal time expressions"
since_and_until.append(fn(*result.groups())) # type: ignore
if not matched:
# default matched case
since_and_until.append(f"DATETIME('{part}')")
_since, _until = map(datetime_eval, since_and_until)
else:
since = since or ""
if since:
since = add_ago_to_since(since)
since = parse_human_datetime(since) if since else None # type: ignore
until = parse_human_datetime(until) if until else relative_end # type: ignore
_since = parse_human_datetime(since) if since else None
_until = (
parse_human_datetime(until)
if until
else parse_human_datetime(_relative_end)
)
if time_shift:
time_delta = parse_past_timedelta(time_shift)
since = since if since is None else (since - time_delta) # type: ignore
until = until if until is None else (until - time_delta) # type: ignore
_since = _since if _since is None else (_since - time_delta)
_until = _until if _until is None else (_until - time_delta)
if since and until and since > until:
if _since and _until and _since > _until:
raise ValueError(_("From date cannot be larger than to date"))
return since, until # type: ignore
return _since, _until
def add_ago_to_since(since: str) -> str:

View File

@ -15,9 +15,12 @@
# specific language governing permissions and limitations
# under the License.
# pylint: disable=R
from typing import Any
import simplejson as json
from flask import request
from flask_appbuilder import expose
from flask_appbuilder.api import rison
from flask_appbuilder.security.decorators import has_access_api
from superset import db, event_logger
@ -26,8 +29,11 @@ from superset.legacy import update_time_range
from superset.models.slice import Slice
from superset.typing import FlaskResponse
from superset.utils import core as utils
from superset.utils.core import get_since_until
from superset.views.base import api, BaseSupersetView, handle_api_exception
get_time_range_schema = {"type": "string"}
class Api(BaseSupersetView):
@event_logger.log_this
@ -70,3 +76,23 @@ class Api(BaseSupersetView):
update_time_range(form_data)
return json.dumps(form_data)
@api
@handle_api_exception
@has_access_api
@rison(get_time_range_schema)
@expose("/v1/time_range/", methods=["GET"])
def time_range(self, **kwargs: Any) -> FlaskResponse:
"""Get actually time range from human readable string or datetime expression"""
time_range = kwargs["rison"]
try:
since, until = get_since_until(time_range)
result = {
"since": since.isoformat() if since else "",
"until": until.isoformat() if until else "",
"timeRange": time_range,
}
return self.json_response({"result": result})
except ValueError as error:
error_msg = {"message": f"Unexpected time range: {error}"}
return self.json_response(error_msg, 400)

View File

@ -982,6 +982,18 @@ class TestChartApi(SupersetTestCase, ApiOwnersTestCaseMixin):
if res["id"] in users_favorite_ids:
assert res["value"]
def test_get_time_range(self):
"""
Chart API: Test get actually time range from human readable string
"""
self.login(username="admin")
humanize_time_range = "100 years ago : now"
uri = f"api/v1/time_range/?q={prison.dumps(humanize_time_range)}"
rv = self.client.get(uri)
data = json.loads(rv.data.decode("utf-8"))
self.assertEqual(rv.status_code, 200)
self.assertEqual(len(data["result"]), 3)
@pytest.mark.usefixtures(
"load_unicode_dashboard_with_slice", "load_energy_table_with_slice"
)

View File

@ -63,6 +63,7 @@ from superset.utils.core import (
validate_json,
zlib_compress,
zlib_decompress,
datetime_eval,
)
from superset.utils import schema
from superset.views.utils import (
@ -154,6 +155,18 @@ class TestUtils(SupersetTestCase):
self.assertEqual(parse_human_timedelta("1 year"), timedelta(366))
self.assertEqual(parse_human_timedelta("-1 year"), timedelta(-365))
self.assertEqual(parse_human_timedelta(None), timedelta(0))
self.assertEqual(
parse_human_timedelta("1 month", datetime(2019, 4, 1)), timedelta(30),
)
self.assertEqual(
parse_human_timedelta("1 month", datetime(2019, 5, 1)), timedelta(31),
)
self.assertEqual(
parse_human_timedelta("1 month", datetime(2019, 2, 1)), timedelta(28),
)
self.assertEqual(
parse_human_timedelta("-1 month", datetime(2019, 2, 1)), timedelta(-31),
)
@patch("superset.utils.core.datetime")
def test_parse_past_timedelta(self, mock_datetime):
@ -708,6 +721,10 @@ class TestUtils(SupersetTestCase):
expected = datetime(2015, 11, 7), datetime(2016, 11, 7)
self.assertEqual(result, expected)
result = get_since_until("Last quarter")
expected = datetime(2016, 8, 7), datetime(2016, 11, 7)
self.assertEqual(result, expected)
result = get_since_until("Last 5 months")
expected = datetime(2016, 6, 7), datetime(2016, 11, 7)
self.assertEqual(result, expected)
@ -747,6 +764,109 @@ class TestUtils(SupersetTestCase):
with self.assertRaises(ValueError):
get_since_until(time_range="tomorrow : yesterday")
@patch("superset.utils.core.parse_human_datetime", mock_parse_human_datetime)
def test_datetime_eval(self):
result = datetime_eval("datetime('now')")
expected = datetime(2016, 11, 7, 9, 30, 10)
self.assertEqual(result, expected)
result = datetime_eval("datetime('today' )")
expected = datetime(2016, 11, 7)
self.assertEqual(result, expected)
# Parse compact arguments spelling
result = datetime_eval("dateadd(datetime('today'),1,year,)")
expected = datetime(2017, 11, 7)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('today'), -2, year)")
expected = datetime(2014, 11, 7)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('today'), 2, quarter)")
expected = datetime(2017, 5, 7)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('today'), 3, month)")
expected = datetime(2017, 2, 7)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('today'), -3, week)")
expected = datetime(2016, 10, 17)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('today'), 3, day)")
expected = datetime(2016, 11, 10)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('now'), 3, hour)")
expected = datetime(2016, 11, 7, 12, 30, 10)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('now'), 40, minute)")
expected = datetime(2016, 11, 7, 10, 10, 10)
self.assertEqual(result, expected)
result = datetime_eval("dateadd(datetime('now'), -11, second)")
expected = datetime(2016, 11, 7, 9, 29, 59)
self.assertEqual(result, expected)
result = datetime_eval("datetrunc(datetime('now'), year)")
expected = datetime(2016, 1, 1, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("datetrunc(datetime('now'), month)")
expected = datetime(2016, 11, 1, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("datetrunc(datetime('now'), day)")
expected = datetime(2016, 11, 7, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("datetrunc(datetime('now'), week)")
expected = datetime(2016, 11, 7, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("datetrunc(datetime('now'), hour)")
expected = datetime(2016, 11, 7, 9, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("datetrunc(datetime('now'), minute)")
expected = datetime(2016, 11, 7, 9, 30, 0)
self.assertEqual(result, expected)
result = datetime_eval("datetrunc(datetime('now'), second)")
expected = datetime(2016, 11, 7, 9, 30, 10)
self.assertEqual(result, expected)
result = datetime_eval("lastday(datetime('now'), year)")
expected = datetime(2016, 12, 31, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("lastday(datetime('today'), month)")
expected = datetime(2016, 11, 30, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("holiday('Christmas')")
expected = datetime(2016, 12, 25, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval("holiday('Labor day', datetime('2018-01-01T00:00:00'))")
expected = datetime(2018, 9, 3, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval(
"holiday('Boxing day', datetime('2018-01-01T00:00:00'), 'UK')"
)
expected = datetime(2018, 12, 26, 0, 0, 0)
self.assertEqual(result, expected)
result = datetime_eval(
"lastday(dateadd(datetime('2018-01-01T00:00:00'), 1, month), month)"
)
expected = datetime(2018, 2, 28, 0, 0, 0)
self.assertEqual(result, expected)
@patch("superset.utils.core.to_adhoc", mock_to_adhoc)
def test_convert_legacy_filters_into_adhoc_where(self):
form_data = {"where": "a = 1"}