Filter by datetime and datetime range.

https://community.openproject.com/work_packages/24174

See also previous PRs under #5071, #5128
pull/5320/head
cklein 8 years ago committed by Jens Ulferts
parent 9aa4d54ea8
commit 8077afd57c
No known key found for this signature in database
GPG Key ID: 3CAA4B1182CF5308
  1. 22
      app/models/queries/base_filter.rb
  2. 144
      app/models/queries/sql_for_calendarial_field.rb
  3. 81
      app/models/queries/sql_for_field.rb
  4. 5
      app/models/query.rb
  5. 62
      frontend/app/components/input/transformers/transform-date-utc.directive.ts
  6. 52
      frontend/app/services/timezone-service.js
  7. 192
      frontend/tests/unit/tests/components/input/transformers/transform-date-utc-test.js
  8. 36
      frontend/tests/unit/tests/services/timezone-service-test.js

@ -70,7 +70,7 @@ class Queries::BaseFilter
list_optional: ['=', '!', '!*', '*'],
list_subprojects: ['*', '!*', '='],
date: ['<t+', '>t+', 't+', 't', 'w', '>t-', '<t-', 't-', '=d', '<>d'],
datetime_past: ['>t-', '<t-', 't-', 't', 'w'],
datetime_past: ['>t-', '<t-', 't-', 't', 'w', '=d', '<>d'],
string: ['=', '~', '!', '!~'],
text: ['~', '!~'],
integer: ['=', '!', '>=', '<=', '!*', '*']
@ -141,7 +141,7 @@ class Queries::BaseFilter
end
def where
sql_for_field(self.class.key, operator, values, self.class.model.table_name, self.class.key)
sql_for_field(self.class.key, operator, values, self.class.model.table_name, self.class.key, false, type)
end
def joins
@ -192,7 +192,11 @@ class Queries::BaseFilter
case type
when :integer, :date, :datetime_past
if operator == '=d' || operator == '<>d'
validate_values_all_date
if type == :date
validate_values_all_date
else
validate_values_all_datetime
end
else
validate_values_all_integer
end
@ -207,6 +211,12 @@ class Queries::BaseFilter
end
end
def validate_values_all_datetime
unless values.all? { |value| value != 'undefined' ? datetime?(value) : true }
errors.add(:values, I18n.t('activerecord.errors.messages.not_a_date'))
end
end
def validate_values_all_integer
unless values.all? { |value| integer?(value) }
errors.add(:values, I18n.t('activerecord.errors.messages.not_an_integer'))
@ -233,4 +243,10 @@ class Queries::BaseFilter
rescue
false
end
def datetime?(str)
true if DateTime.parse(str)
rescue
false
end
end

@ -0,0 +1,144 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module Queries::SqlForCalendarialField
private
def sql_for_date_field(field, operator, values, db_table, db_field)
if operator == '=d'
date_range_clause(db_table, db_field, Date.parse(values.first),
Date.parse(values.first))
elsif operator == '<>d'
if values.first != 'undefined'
from = Date.parse(values.first)
end
if values.size == 2
to = Date.parse(values.last)
end
date_range_clause(db_table, db_field, from, to)
else
sql_for_calendarial_field(field, operator, values, db_table, db_field)
end
end
def sql_for_datetime_field(field, operator, values, db_table, db_field)
if operator == '=d'
datetime = DateTime.parse(values.first)
datetime_range_clause(db_table, db_field, datetime.beginning_of_day,
datetime.end_of_day)
elsif operator == '<>d'
if values.first != 'undefined'
from = DateTime.parse(values.first).beginning_of_day
end
if values.size == 2
to = DateTime.parse(values.last).end_of_day
end
datetime_range_clause(db_table, db_field, from, to)
else
sql_for_calendarial_field(field, operator, values, db_table, db_field)
end
end
def sql_for_calendarial_field(field, operator, values, db_table, db_field)
case operator
when '>t-'
relative_date_range_clause(db_table, db_field, - values.first.to_i, 0)
when '<t-'
relative_date_range_clause(db_table, db_field, nil, - values.first.to_i)
when 't-'
relative_date_range_clause(db_table, db_field, - values.first.to_i,
- values.first.to_i)
when '>t+'
relative_date_range_clause(db_table, db_field, values.first.to_i, nil)
when '<t+'
relative_date_range_clause(db_table, db_field, 0, values.first.to_i)
when 't+'
relative_date_range_clause(db_table, db_field, values.first.to_i, values.first.to_i)
when 't'
relative_date_range_clause(db_table, db_field, 0, 0)
when 'w'
from = begin_of_week
"#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [
connection.quoted_date(from), connection.quoted_date(from + 7.days)
]
end
end
def begin_of_week
if l(:general_first_day_of_week) == '7'
# week starts on sunday
if Date.today.cwday == 7
Time.now.at_beginning_of_day
else
Time.now.at_beginning_of_week - 1.day
end
else
# week starts on monday (Rails default)
Time.now.at_beginning_of_week
end
end
# Returns a SQL clause for a date or datetime field for a relative range from
# the end of the day of yesterday + from until the end of today + to.
def relative_date_range_clause(table, field, from, to)
if from
from_date = Date.today + from
end
if to
to_date = Date.today + to
end
date_range_clause(table, field, from_date, to_date)
end
# Returns a SQL clause for date or datetime field for an exact range starting
# at the beginning of the day of from until the end of the day of to
def date_range_clause(table, field, from, to)
s = []
if from
s << "#{table}.#{field} > '%s'" % [
connection.quoted_date(from.yesterday.to_time(:utc).end_of_day)
]
end
if to
s << "#{table}.#{field} <= '%s'" % [connection.quoted_date(to.to_time(:utc).end_of_day)]
end
s.join(' AND ')
end
def datetime_range_clause(table, field, from, to)
s = []
if from
s << ("#{table}.#{field} >= '%s'" % [connection.quoted_date(from)])
end
if to
s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to)])
end
s.join(' AND ')
end
end

@ -28,10 +28,13 @@
#++
module Queries::SqlForField
include Queries::SqlForCalendarialField
private
# Helper method to generate the WHERE sql for a +field+, +operator+ and a +values+ array
def sql_for_field(field, operator, values, db_table, db_field, is_custom_filter = false)
def sql_for_field(field, operator, values, db_table, db_field, is_custom_filter = false,
type = nil)
# code expects strings (e.g. for quoting), but ints would work as well: unify them here
values = values.map(&:to_s)
@ -81,86 +84,20 @@ module Queries::SqlForField
sql = "#{Status.table_name}.is_closed=#{connection.quoted_false}" if field == 'status_id'
when 'c'
sql = "#{Status.table_name}.is_closed=#{connection.quoted_true}" if field == 'status_id'
when '>t-'
sql = relative_date_range_clause(db_table, db_field, - values.first.to_i, 0)
when '<t-'
sql = relative_date_range_clause(db_table, db_field, nil, - values.first.to_i)
when 't-'
sql = relative_date_range_clause(db_table, db_field,
- values.first.to_i, - values.first.to_i)
when '>t+'
sql = relative_date_range_clause(db_table, db_field, values.first.to_i, nil)
when '<t+'
sql = relative_date_range_clause(db_table, db_field, 0, values.first.to_i)
when 't+'
sql = relative_date_range_clause(db_table, db_field, values.first.to_i, values.first.to_i)
when 't'
sql = relative_date_range_clause(db_table, db_field, 0, 0)
when 'w'
from = begin_of_week
sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [
connection.quoted_date(from), connection.quoted_date(from + 7.days)
]
when '~'
sql = "LOWER(#{db_table}.#{db_field}) LIKE " +
"'%#{connection.quote_string(values.first.to_s.downcase)}%'"
when '!~'
sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE " +
"'%#{connection.quote_string(values.first.to_s.downcase)}%'"
when '=d'
sql = date_range_clause(db_table, db_field,
Date.parse(values.first), Date.parse(values.first))
when '<>d'
if values.first != 'undefined'
from = Date.parse(values.first)
end
if values.size == 2
to = Date.parse(values.last)
end
sql = date_range_clause(db_table, db_field, from, to)
end
sql
end
def begin_of_week
if l(:general_first_day_of_week) == '7'
# week starts on sunday
if Date.today.cwday == 7
Time.now.at_beginning_of_day
else
if type == 'date'
sql = sql_for_date_field(field, operator, values, db_table, db_field)
else
Time.now.at_beginning_of_week - 1.day
sql = sql_for_datetime_field(field, operator, values, db_table, db_field)
end
else
# week starts on monday (Rails default)
Time.now.at_beginning_of_week
end
end
# Returns a SQL clause for a date or datetime field for a relative range from
# the end of the day of yesterday + from until the end of today + to.
def relative_date_range_clause(table, field, from, to)
if from
from_date = Date.today + from
end
if to
to_date = Date.today + to
end
date_range_clause(table, field, from_date, to_date)
end
# Returns a SQL clause for date or datetime field for an exact range starting
# at the beginning of the day of from until the end of the day of to
def date_range_clause(table, field, from, to)
s = []
if from
s << "#{table}.#{field} > '%s'" % [
connection.quoted_date(from.yesterday.to_time(:utc).end_of_day)
]
end
if to
s << "#{table}.#{field} <= '%s'" % [connection.quoted_date(to.to_time(:utc).end_of_day)]
end
s.join(' AND ')
sql
end
def connection

@ -485,6 +485,7 @@ class Query < ActiveRecord::Base
next if field == 'subproject_id'
operator = filter.operator
type = filter.type
values = filter.values ? filter.values.clone : [''] # HACK - some operators don't require values, but they are needed for building the statement
# "me" value substitution
@ -506,7 +507,7 @@ class Query < ActiveRecord::Base
db_field = 'value'
is_custom_filter = true
sql << "#{WorkPackage.table_name}.id IN (SELECT #{WorkPackage.table_name}.id FROM #{WorkPackage.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='WorkPackage' AND #{db_table}.customized_id=#{WorkPackage.table_name}.id AND #{db_table}.custom_field_id=#{$1} WHERE "
sql << sql_for_field(field, operator, values, db_table, db_field, true) + ')'
sql << sql_for_field(field, operator, values, db_table, db_field, true, type) + ')'
elsif field == 'watcher_id'
db_table = Watcher.table_name
db_field = 'user_id'
@ -578,7 +579,7 @@ class Query < ActiveRecord::Base
# regular field
db_table = WorkPackage.table_name
db_field = field
sql << '(' + sql_for_field(field, operator, values, db_table, db_field) + ')'
sql << '(' + sql_for_field(field, operator, values, db_table, db_field, false, type) + ')'
end
filters_clauses << sql
end if filters.present? and valid?

@ -0,0 +1,62 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
function transformDateUtc(TimezoneService) {
return {
restrict: 'A',
scope: {
transformDateUtc: '@'
},
require: '^ngModel',
link: function (scope, element, attrs, ngModelController) {
ngModelController.$parsers.push(function (data) {
if (!moment(data, 'YYYY-MM-DD', true).isValid()) {
return undefined;
}
var d = TimezoneService.parseLocalDate(data);
if (scope.transformDateUtc == 'end-of-day') {
d.endOf('day');
} else { // begin-of-day
d.startOf('day');
}
return TimezoneService.formattedISODatetime(d);
});
ngModelController.$formatters.push(function (data) {
if (!moment(data, 'YYYY-MM-DDTHH:mm:ssZ', true).isValid()) {
return undefined;
}
var d = TimezoneService.parseISODatetime(data);
return TimezoneService.formattedISODate(d);
});
}
};
}
angular
.module('openproject')
.directive('transformDateUtc', transformDateUtc);

@ -48,6 +48,45 @@ module.exports = function(ConfigurationService, I18n) {
return moment(date, format);
},
/**
* Parses a string that is considered to be a local date, which is always considered to be in UTC.
* If the user's settings define a different timezone, then that timezone is applied.
*
* @param {String} date
* @param {String} format
* @returns {Moment}
*/
parseLocalDate: function(date, format) {
var result = moment.utc(date, format);
if (ConfigurationService.isTimezoneSet()) {
result.local();
result.tz(ConfigurationService.timezone());
}
return result;
},
/**
* Parses the specified datetime and applies the user's configured timezone, if any.
*
* This will effectfully transform the [server] provided datetime object to the user's configured local timezone.
*
* @param {String} datetime in 'YYYY-MM-DDTHH:mm:ssZ' format
* @returns {Moment}
*/
parseISODatetime: function(datetime) {
var result = moment(datetime, 'YYYY-MM-DDTHH:mm:ssZ');
if (ConfigurationService.isTimezoneSet()) {
// TODO:needs to be moved out of the conditional, all date/datetimes need to be displayed using the system timezone
result.local();
result.tz(ConfigurationService.timezone());
}
return result;
},
parseISODate: function(date) {
return TimezoneService.parseDate(date, 'YYYY-MM-DD');
},
@ -86,6 +125,10 @@ module.exports = function(ConfigurationService, I18n) {
return TimezoneService.parseDate(date).format('YYYY-MM-DD');
},
formattedISODatetime: function(datetime) {
return datetime.format('YYYY-MM-DDTHH:mm:ssZ');
},
isValidISODate: function(date) {
return TimezoneService.isValid(date, 'YYYY-MM-DD');
},
@ -102,6 +145,15 @@ module.exports = function(ConfigurationService, I18n) {
getTimeFormat: function() {
return ConfigurationService.timeFormatPresent() ? ConfigurationService.timeFormat() : 'LT';
},
getTimezoneNG: function() {
var now = moment().utc();
if (ConfigurationService.isTimezoneSet()) {
now.local();
now.tz(ConfigurationService.timezone());
}
return now.format('ZZ');
}
};

@ -0,0 +1,192 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
describe('transformDateUtc Directive', function() {
var compile, element, rootScope, scope, ConfigurationService, isTimezoneSetStub, timezoneStub;
var prepare = function (timeOfDay) {
angular.mock.module('openproject');
inject(function($rootScope, $compile, _ConfigurationService_) {
var html =
'<input type="text" ng-model="dateValue" transform-date-utc="' + timeOfDay + '"/>';
ConfigurationService = _ConfigurationService_;
isTimezoneSetStub = sinon.stub(ConfigurationService, 'isTimezoneSet');
isTimezoneSetStub.returns(true);
timezoneStub = sinon.stub(ConfigurationService, 'timezone');
element = angular.element(html);
rootScope = $rootScope;
scope = $rootScope.$new();
scope.dateValue = null;
compile = function () {
$compile(element)(scope);
scope.$digest();
};
});
};
var shouldBehaveLikeAParser = function (value, expected) {
it('these should parse as expected', function () {
compile();
element.val(value);
element.trigger('input');
expect(scope.dateValue).to.eql(expected);
});
};
var shouldBehaveLikeAFormatter = function (value, expected) {
it('should format the value as expected', function () {
scope.dateValue = value;
compile();
expect(element.val()).to.eql(expected);
});
}
var shouldBehaveCorrectlyWhenGivenInvalidDateValues = function () {
describe('when given invalid date values', function () {
shouldBehaveLikeAParser('', undefined);
shouldBehaveLikeAParser('invalid', undefined);
shouldBehaveLikeAParser('2016-12', undefined);
shouldBehaveLikeAFormatter(undefined, '');
shouldBehaveLikeAFormatter('invalid', '');
shouldBehaveLikeAFormatter('2016-12', '');
});
};
context('should behave like begin-of-day with no time-of-day value given', function () {
beforeEach(function () {
prepare();
timezoneStub.returns('UTC');
});
describe('when given valid date values', function() {
var value = '2016-12-01';
var expectedValue = value + 'T00:00:00+00:00';
shouldBehaveLikeAParser(value, expectedValue);
shouldBehaveLikeAFormatter(expectedValue, value);
});
shouldBehaveCorrectlyWhenGivenInvalidDateValues();
});
context('with begin-of-day', function () {
beforeEach(function () {
prepare('begin-of-day');
timezoneStub.returns('UTC');
});
describe('when given valid date values', function() {
var value = '2016-12-01';
var expectedValue = value + 'T00:00:00+00:00';
shouldBehaveLikeAParser(value, expectedValue);
shouldBehaveLikeAFormatter(expectedValue, value);
});
shouldBehaveCorrectlyWhenGivenInvalidDateValues();
});
context('with end-of-day', function () {
beforeEach(function () {
prepare('end-of-day');
timezoneStub.returns('UTC');
});
describe('when given valid date values', function() {
var value = '2016-12-01';
var expectedValue = value + 'T23:59:59+00:00';
shouldBehaveLikeAParser(value, expectedValue);
shouldBehaveLikeAFormatter(expectedValue, value);
});
shouldBehaveCorrectlyWhenGivenInvalidDateValues();
});
context('when receiving datetime values from a different timezone', function () {
context('with begin-of-day', function () {
beforeEach(function () {
prepare('begin-of-day');
// moment-timezone: GMT+1 is actually GMT-1
timezoneStub.returns('Etc/GMT+1');
});
describe('it should be transformed to the local timezone', function () {
var value = '2016-12-03T00:00:00+00:00';
var expectedValue = '2016-12-02';
shouldBehaveLikeAFormatter(value, expectedValue);
});
});
context('with end-of-day', function () {
beforeEach(function () {
prepare('end-of-day');
// moment-timezone: GMT-1 is actually GMT+1
timezoneStub.returns('Etc/GMT-1');
});
describe('it should be transformed to the local timezone', function () {
var value = '2016-12-01T23:59:59+00:00';
var expectedValue = '2016-12-02';
shouldBehaveLikeAFormatter(value, expectedValue);
});
});
});
context('when operating in a different timezone than UTC', function () {
context('with begin-of-day', function () {
beforeEach(function () {
prepare('begin-of-day');
// moment-timezone: GMT-1 is actually GMT+1
timezoneStub.returns('Etc/GMT-1');
});
describe('it should have the expected timezone offset', function () {
var value = '2016-12-01';
var expectedValue = value + 'T00:00:00+01:00';
shouldBehaveLikeAParser(value, expectedValue);
});
});
context('with end-of-day', function () {
beforeEach(function () {
prepare('end-of-day');
// moment-timezone: GMT+1 is actually GMT-1
timezoneStub.returns('Etc/GMT+1');
});
describe('it should have the expected timezone offset', function () {
var value = '2016-12-01';
var expectedValue = '2016-11-30T23:59:59-01:00';
shouldBehaveLikeAParser(value, expectedValue);
});
});
});
});

@ -82,4 +82,40 @@ describe('TimezoneService', function() {
expect(time.format('HH:mm')).to.eq('00:00');
});
});
// describe('#parseLocalDate', function() {
// it('has UTC time zone', function() {
// var time = TimezoneService.parseLocalDate(DATE, 'YYYY-MM-DD');
// expect(time.utcOffset()).to.equal(0);
// });
//
// it('has no time information', function() {
// var time = TimezoneService.parseLocalDate(DATE, 'YYYY-MM-DD');
// expect(time.format('HH:mm')).to.eq('00:00');
// });
// });
//
// describe('#parseISODatetime', function() {
// it('has UTC time zone', function() {
// var time = TimezoneService.parseISODatetime(DATETIME);
// expect(time.utcOffset()).to.equal(0);
// });
//
// it('has no time information', function() {
// var time = TimezoneService.parseLocalDate(DATE, 'YYYY-MM-DD');
// expect(time.format('HH:mm')).to.eq('00:00');
// });
// });
//
// describe('#getTimezoneNG', function() {
// it('has UTC time zone', function() {
// var time = TimezoneService.parseLocalDate(DATE, 'YYYY-MM-DD');
// expect(time.utcOffset()).to.equal(0);
// });
//
// it('has no time information', function() {
// var time = TimezoneService.parseLocalDate(DATE, 'YYYY-MM-DD');
// expect(time.format('HH:mm')).to.eq('00:00');
// });
// });
});

Loading…
Cancel
Save