From 8077afd57c5af5ff0e20f5f1752de4bbba4f474f Mon Sep 17 00:00:00 2001 From: cklein Date: Tue, 17 Jan 2017 17:04:01 +0100 Subject: [PATCH] Filter by datetime and datetime range. https://community.openproject.com/work_packages/24174 See also previous PRs under #5071, #5128 --- app/models/queries/base_filter.rb | 22 +- .../queries/sql_for_calendarial_field.rb | 144 +++++++++++++ app/models/queries/sql_for_field.rb | 81 +------- app/models/query.rb | 5 +- .../transform-date-utc.directive.ts | 62 ++++++ frontend/app/services/timezone-service.js | 52 +++++ .../transformers/transform-date-utc-test.js | 192 ++++++++++++++++++ .../tests/services/timezone-service-test.js | 36 ++++ 8 files changed, 517 insertions(+), 77 deletions(-) create mode 100644 app/models/queries/sql_for_calendarial_field.rb create mode 100644 frontend/app/components/input/transformers/transform-date-utc.directive.ts create mode 100644 frontend/tests/unit/tests/components/input/transformers/transform-date-utc-test.js diff --git a/app/models/queries/base_filter.rb b/app/models/queries/base_filter.rb index 682ea87a40..63d03345d6 100644 --- a/app/models/queries/base_filter.rb +++ b/app/models/queries/base_filter.rb @@ -70,7 +70,7 @@ class Queries::BaseFilter list_optional: ['=', '!', '!*', '*'], list_subprojects: ['*', '!*', '='], date: ['t+', 't+', 't', 'w', '>t-', 'd'], - datetime_past: ['>t-', 't-', '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 diff --git a/app/models/queries/sql_for_calendarial_field.rb b/app/models/queries/sql_for_calendarial_field.rb new file mode 100644 index 0000000000..c73ba51c41 --- /dev/null +++ b/app/models/queries/sql_for_calendarial_field.rb @@ -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, values.first.to_i, nil) + when ' '%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 diff --git a/app/models/queries/sql_for_field.rb b/app/models/queries/sql_for_field.rb index 05f459a767..f0e01f0cf0 100644 --- a/app/models/queries/sql_for_field.rb +++ b/app/models/queries/sql_for_field.rb @@ -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, values.first.to_i, nil) - 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 diff --git a/app/models/query.rb b/app/models/query.rb index 80b443a243..3000d265c5 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -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? diff --git a/frontend/app/components/input/transformers/transform-date-utc.directive.ts b/frontend/app/components/input/transformers/transform-date-utc.directive.ts new file mode 100644 index 0000000000..5cbd195c26 --- /dev/null +++ b/frontend/app/components/input/transformers/transform-date-utc.directive.ts @@ -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); diff --git a/frontend/app/services/timezone-service.js b/frontend/app/services/timezone-service.js index 9406db26f3..faec5322b3 100644 --- a/frontend/app/services/timezone-service.js +++ b/frontend/app/services/timezone-service.js @@ -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'); } }; diff --git a/frontend/tests/unit/tests/components/input/transformers/transform-date-utc-test.js b/frontend/tests/unit/tests/components/input/transformers/transform-date-utc-test.js new file mode 100644 index 0000000000..b11f1b0b48 --- /dev/null +++ b/frontend/tests/unit/tests/components/input/transformers/transform-date-utc-test.js @@ -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 = + ''; + + 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); + }); + }); + }); +}); diff --git a/frontend/tests/unit/tests/services/timezone-service-test.js b/frontend/tests/unit/tests/services/timezone-service-test.js index 21c114d2de..f9b727b6e0 100644 --- a/frontend/tests/unit/tests/services/timezone-service-test.js +++ b/frontend/tests/unit/tests/services/timezone-service-test.js @@ -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'); + // }); + // }); });