Merge pull request #478 from opf/feature/planning_comparison

pull/498/head
Hagen Schink 11 years ago
commit af20ca70c7
  1. 120
      app/assets/javascripts/timelines.js
  2. 32
      app/controllers/api/v2/planning_elements_controller.rb
  3. 101
      app/services/planning_comparison_service.rb
  4. 8
      app/views/timelines/_form.html.erb
  5. 7
      doc/CHANGELOG.md
  6. 46
      features/planning_elements/filter.feature
  7. 44
      features/step_definitions/api_steps.rb
  8. 60
      features/step_definitions/planning_element_steps.rb
  9. 30
      features/step_definitions/timecop_steps.rb
  10. 94
      features/step_definitions/timelines_comparison_steps.rb
  11. 35
      features/step_definitions/timelines_given_steps.rb
  12. 8
      features/step_definitions/work_package_steps.rb
  13. 81
      features/timelines/timeline_comparison_view.feature.disabled
  14. 26
      features/timelines/timeline_modal_views.feature
  15. 6
      features/timelines/timeline_view.feature
  16. 14
      features/timelines/timeline_wiki_macro.feature
  17. 148
      spec/models/work_package/planning_comparison_spec.rb

@ -428,7 +428,8 @@ Timeline = {
* Simple serializer of query strings that satisfies OpenProject's filter
* API. Transforms hashes of desired filterings into the proper query strings.
*
* Example:
* Examples:
*
* fqsb = (new FilterQueryStringBuilder({
* 'type_id': [4, 5]
* })).build(
@ -437,34 +438,51 @@ Timeline = {
*
* => /api/v2/projects/sample_project/planning_elements.json?f[]=type_id&op[type_id]==&v[type_id][]=4&v[type_id][]=5
*
* fqsb = (new FilterQueryStringBuilder())
* .filter({ 'type_id': [4, 5] })
* .append({ 'at_time': 1380795754 })
* .build( '/api/v2/projects/sample_project/planning_elements.json' );
*
* => /api/v2/projects/sample_project/planning_elements.json?f[]=type_id&op[type_id]==&v[type_id][]=4&v[type_id][]=5&at_time=1380795754
*/
var FilterQueryStringBuilder = function (filterHash) {
this.filterHash = filterHash;
this.filterHash = filterHash || {};
this.paramsHash = {};
};
FilterQueryStringBuilder.prototype.filter = function(filters) {
this.filterHash = jQuery.extend({}, this.filterHash, filters);
return this;
};
FilterQueryStringBuilder.prototype.append = function(addition) {
this.paramsHash = jQuery.extend({}, this.paramsHash, addition);
return this;
};
FilterQueryStringBuilder.prototype.buildMetaDataForKey = function(key) {
this.queryStringParts.push(
{name: 'f[]', value: key},
{name: 'op[' + key + ']', value: '='}
);
this.queryStringParts.push({name: 'f[]', value: key},
{name: 'op[' + key + ']', value: '='});
};
FilterQueryStringBuilder.prototype.buildFilterDataForKeyAndValue = function(key, value) {
this.queryStringParts.push(
{name: 'v[' + key + '][]', value: value}
);
FilterQueryStringBuilder.prototype.prepareFilterDataForKeyAndValue = function(key, value) {
this.queryStringParts.push({name: 'v[' + key + '][]', value: value});
};
FilterQueryStringBuilder.prototype.buildFilterDataForKeyAndArrayOfValues = function(key, value) {
FilterQueryStringBuilder.prototype.prepareAdditionalQueryData = function(key, value) {
this.queryStringParts.push({name: key, value: value});
}
FilterQueryStringBuilder.prototype.prepareFilterDataForKeyAndArrayOfValues = function(key, value) {
jQuery.each(value, jQuery.proxy( function(i, e) {
this.buildFilterDataForKeyAndValue(key, e);
this.prepareFilterDataForKeyAndValue(key, e);
}, this));
};
FilterQueryStringBuilder.prototype.buildFilterDataForValue = function(key, value) {
return value instanceof Array ?
this.buildFilterDataForKeyAndArrayOfValues(key, value) :
this.buildFilterDataForKeyAndValue(key, value);
this.prepareFilterDataForKeyAndArrayOfValues(key, value) :
this.prepareFilterDataForKeyAndValue(key, value);
};
FilterQueryStringBuilder.prototype.registerKeyAndValue = function(key, value) {
@ -472,9 +490,10 @@ Timeline = {
this.buildFilterDataForValue(key, value);
};
FilterQueryStringBuilder.prototype.buildQueryStringParts = function() {
FilterQueryStringBuilder.prototype.prepareQueryStringParts = function() {
this.queryStringParts = [];
jQuery.each(this.filterHash, jQuery.proxy(this.registerKeyAndValue, this));
jQuery.each(this.paramsHash, jQuery.proxy(this.prepareAdditionalQueryData, this));
};
FilterQueryStringBuilder.prototype.buildQueryStringFromQueryStringParts = function(url) {
@ -491,7 +510,7 @@ Timeline = {
};
FilterQueryStringBuilder.prototype.build = function(url) {
this.buildQueryStringParts();
this.prepareQueryStringParts();
return this.buildUrlFromQueryStringParts(url);
};
@ -1125,28 +1144,23 @@ Timeline = {
var qsb = new Timeline.FilterQueryStringBuilder(
this.provideServerSideFilterHash());
var url = qsb.build(projectPrefix + '/planning_elements.json');
// load current planning elements.
this.loader.register(
Timeline.PlanningElement.identifier + '_' + i,
{ url : url },
{ storeIn: Timeline.PlanningElement.identifier }
);
Timeline.PlanningElement.identifier + '_' + i,
{ url : qsb.build(projectPrefix + '/planning_elements.json') },
{ storeIn: Timeline.PlanningElement.identifier }
);
/* TODO!
// load historical planning elements.
if (this.options.target_time) {
this.loader.register(
Timeline.HistoricalPlanningElement.identifier + '_' + i,
{ url : projectPrefix +
'/planning_elements.json' +
this.comparisonTargetUrlSuffix() },
{ storeIn: Timeline.HistoricalPlanningElement.identifier,
readFrom: Timeline.PlanningElement.identifier }
);
Timeline.HistoricalPlanningElement.identifier + '_' + i,
{ url : qsb.append({ at_time: this.options.target_time })
.build(projectPrefix + '/planning_elements.json') },
{ storeIn: Timeline.HistoricalPlanningElement.identifier,
readFrom: Timeline.PlanningElement.identifier }
);
}
*/
});
};
@ -1159,26 +1173,24 @@ Timeline = {
// load current planning elements.
this.loader.register(
Timeline.PlanningElement.identifier + '_IDS_' + i,
{ url : projectPrefix +
'/planning_elements.json?ids=' +
planningElementIdsOfPacket.join(',')},
{ storeIn: Timeline.PlanningElement.identifier }
);
Timeline.PlanningElement.identifier + '_IDS_' + i,
{ url : projectPrefix +
'/planning_elements.json?ids=' +
planningElementIdsOfPacket.join(',')},
{ storeIn: Timeline.PlanningElement.identifier }
);
/* TODO!
// load historical planning elements.
if (this.options.target_time) {
this.loader.register(
Timeline.HistoricalPlanningElement.identifier + '_IDS_' + i,
{ url : planningElementPrefix +
'/planning_elements.json?ids=' +
planningElementIdsOfPacket.join(',') },
{ storeIn: Timeline.HistoricalPlanningElement.identifier,
readFrom: Timeline.PlanningElement.identifier }
);
Timeline.HistoricalPlanningElement.identifier + '_IDS_' + i,
{ url : planningElementPrefix +
'/planning_elements.json?ids=' +
planningElementIdsOfPacket.join(',') },
{ storeIn: Timeline.HistoricalPlanningElement.identifier,
readFrom: Timeline.PlanningElement.identifier }
);
}
*/
});
};
@ -1197,22 +1209,6 @@ Timeline = {
}
};
TimelineLoader.prototype.comparisonCurrentUrlSuffix = function () {
if (this.options.current_time !== undefined) {
return "&at=" + this.options.current_time;
} else {
return "";
}
};
TimelineLoader.prototype.comparisonTargetUrlSuffix = function () {
if (this.options.target_time !== undefined ) {
return "&at=" + this.options.target_time;
} else {
return "";
}
};
TimelineLoader.prototype.storeData = function (data, identifier) {
if (!jQuery.isArray(data)) {
this.die("Expected an instance of Array. Got something else. This " +

@ -169,13 +169,30 @@ module Api
# is called as a before filter and as a method
def assign_planning_elements(projects = (@projects || [@project]))
@planning_elements = WorkPackage.for_projects(projects).without_deleted
query = Query.new
query.add_filters(params[:f], params[:op], params[:v]) if params[:f]
@planning_elements = if params[:at_time]
historical_work_packages(projects)
else
current_work_packages(projects)
end
end
def current_work_packages(projects)
work_packages = WorkPackage.for_projects(projects).without_deleted
if params[:f]
query = Query.new
query.add_filters(params[:f], params[:op], params[:v])
work_packages = work_packages.with_query query
end
@planning_elements = @planning_elements.with_query query
work_packages
end
def historical_work_packages(projects)
at_time = Time.at(params[:at_time].to_i).to_datetime
filter = params[:f] ? {f: params[:f], op: params[:op], v: params[:v]}: {}
historical = PlanningComparisonService.compare(projects, at_time, filter)
end
# remove this and replace by calls it with calls
@ -205,12 +222,9 @@ module Api
end
def optimize_planning_elements_for_less_db_queries
# abort if @planning_elements is already an array, using .class check since
# .is_a? acts weird on named scopes
return if @planning_elements.class == Array
# triggering full load to avoid separate queries for count or related models
@planning_elements = @planning_elements.all(:include => [:type, :status, :project])
# historical packages are already loaded correctly and only need to be optimised, so they do not need to fetched again, only optimised
@planning_elements = @planning_elements.all(:include => [:type, :status, :project]) unless @planning_elements.class == Array
# Replacing association proxies with already loaded instances to avoid
# further db calls.

@ -0,0 +1,101 @@
class PlanningComparisonService
@@journal_sql = <<SQL
select #{Journal.table_name}.id
from #{Journal.table_name}
inner join (select journable_id, max(created_at) as latest_date, max(id) as latest_id
from #{Journal.table_name}
where #{Journal.table_name}.created_at <= ?
and #{Journal.table_name}.journable_type = 'WorkPackage'
and #{Journal.table_name}.journable_id in (?)
group by #{Journal.table_name}.journable_id) as latest
on #{Journal.table_name}.journable_id=latest.journable_id
where #{Journal.table_name}.created_at=latest.latest_date
and #{Journal.table_name}.id=latest.latest_id;
SQL
@@mapped_attributes = Journal::WorkPackageJournal.journaled_attributes.map{|attribute| "#{Journal::WorkPackageJournal.table_name}.#{attribute}"}.join ','
@@work_package_select = <<SQL
Select #{Journal.table_name}.journable_id as id,
#{Journal.table_name}.created_at as created_at,
#{Journal.table_name}.created_at as updated_at,
#{@@mapped_attributes}
from #{Journal::WorkPackageJournal.table_name}
left join #{Journal.table_name}
on #{Journal.table_name}.id = #{Journal::WorkPackageJournal.table_name}.journal_id
where #{Journal::WorkPackageJournal.table_name}.journal_id in (?)
SQL
# there is currently no possibility to compare two given dates:
# the comparison always works on the current date, filters the current workpackages
# and returns the state of these work_packages at the given time
# filters are given in the format expected by Query and are just passed through to query
def self.compare(projects, at_time, filter={})
# The query uses three steps to find the journalized entries for the filtered workpackages
# at the given point in time:
# 1 filter the ids using query
# 2 find out the latest journal-entries for the given date belonging to the filtered ids
# 3 fetch the data for these journals from Journal::WorkPackageData
# 4 fill theses journal-data into a workpackage
# 1 either filter the ids using the given filter or pluck all work_package-ids from the project
work_package_ids = if filter.has_key? :f
work_package_scope = WorkPackage.scoped
.joins(:status)
.joins(:project) #no idea, why query doesn't provide these joins itself...
.for_projects(projects)
.without_deleted
query = Query.new
query.add_filters(filter[:f], filter[:op], filter[:v])
#TODO teach query to fetch only ids
work_package_scope.with_query(query)
.pluck(:id)
else
WorkPackage.for_projects(projects).pluck(:id)
end
# 2 fetch latest journal-entries for the given time
journal_ids = Journal.find_by_sql([@@journal_sql, at_time, work_package_ids])
.map(&:id)
# 3&4 fetch the journaled data and make rails think it is actually a work_package
work_packages = WorkPackage.find_by_sql([@@work_package_select,journal_ids])
restore_references(work_packages)
end
protected
# This is a very crude way to work around n+1-issues, that are
# introduced by the json/xml-rendering
# the simple .includes does not work the work due to the find_by_sql
def self.restore_references(work_packages)
project_ids, parent_ids, type_ids, status_ids = resolve_reference_ids(work_packages)
projects = Hash[Project.find(project_ids).map {|wp| [wp.id,wp]}]
types = Hash[Type.find(type_ids).map{|type| [type.id,type]}]
statuses = Hash[Status.find(status_ids).map{|status| [status.id,status]}]
work_packages.each do |wp|
wp.project = projects[wp.project_id]
wp.type = types[wp.type_id]
wp.status = statuses[wp.status_id]
end
work_packages
end
def self.resolve_reference_ids(work_packages)
# TODO faster ways to do this without stepping numerous times through the workpackages?!
# Or simply wait until we finally throw out the redundant references out of the json/xml-rendering??!
project_ids = work_packages.map(&:project_id).uniq.compact
type_ids = work_packages.map(&:type_id).uniq.compact
status_ids = work_packages.map(&:status_id).uniq.compact
parent_ids = work_packages.map(&:parent_id).uniq.compact
return project_ids, parent_ids, type_ids,status_ids
end
end

@ -41,13 +41,7 @@ See doc/COPYRIGHT.rdoc for more details.
<%= render :partial => "timelines/general", :locals => {:timeline => @timeline, :f => f}%>
<%# comparisons were removed so that they don't break for everyone once the
journalization changes start.
render :partial => "timelines/comparison", :locals => {:timeline => @timeline}
TODO enable comparisons once journalization is done.
%>
<%= render :partial => "timelines/comparison", :locals => {:timeline => @timeline} %>
<%= render :partial => "timelines/vertical_planning_elements", :locals => {:timeline => @timeline}%>

@ -32,7 +32,13 @@ See doc/COPYRIGHT.rdoc for more details.
* `#1281` I18n.js Not working correctly. Always returns English Translations
* `#1758` Migrate functional-tests for issues into specs for work package
* `#1771` Fixed bug: Refactor Types Project Settings into new Tab
* `#1880` Re-introduce at-time scope
* `#1881` Re-introduce project planning comparison in controller
* `#1883` Extend at-time scope for status comparison
* `#1884` Make status values available over API
* `#1994` Integrational tests for work packages at_time (API)
* `#2158` Work Package General Setting
* `#2173` Adapt client-side to new server behavior
* `#2306` Migrate issues controller tests
* `#2307` Change icon of home button in header from OpenProjct icon to house icon
* `#2310` Add proper indices to work_package
@ -42,6 +48,7 @@ See doc/COPYRIGHT.rdoc for more details.
* `#1560` WorkPackage/update does not retain some fields when validations fail
* `#1771` Refactor Types Project Settings into new Tab
* `#1878` Project Plan Comparison(server-side implementation): api/v2 can now resolve historical data for work_packages
* `#1929` Too many lines in work package view
* `#1946` Modal shown within in Modal
* `#1949` External links within modals do not work

@ -61,11 +61,17 @@ Feature: Filtering work packages via the api
| view_work_packages |
And there is 1 user with the following:
| login | bob |
| login | bob |
| firstname | Bob |
| lastname | Bobbit |
And there is 1 user with the following:
| login | peter |
| login | peter |
| firstname | Peter |
| lastname | Gunn |
And there is 1 user with the following:
| login | pamela |
| login | pamela |
| firstname | Pamela |
| lastname | Anderson |
And the user "bob" is a "member" in the project "sample_project"
And the user "peter" is a "member" in the project "sample_project"
And the user "pamela" is a "member" in the project "sample_project"
@ -206,4 +212,36 @@ Feature: Filtering work packages via the api
And the json-response should not contain a work_package "work_package#1"
And the json-response should contain a work_package "work_package#2"
# Always make sure, that historical tests are tagged with @timetravel:
# otherwise the time remains frozen for other features!!!
Scenario: looking up historical data
Given the date is "2010/01/01"
And there are the following work packages in project "sample_project":
| subject | type | responsible |
| work_package#1 | Task | bob |
| work_package#2 | Task | peter |
| work_package#3 | Task | pamela |
Given the date is "2010/02/01"
And the work_package "work_package#3" is updated with the following:
| type | Story |
| responsible | bob |
Given the date is "2010/03/01"
And I call the work_package-api on project "sample_project" at time "2010/01/03" and filter for types "Story"
Then the json-response should include 1 work package
And the json-response for work_package "work_package#3" should have the type "Task"
And the json-response for work_package "work_package#3" should have the responsible "Pamela Anderson"
Scenario: comparing due dates
Given the date is "2010/01/01"
And there are the following work packages in project "sample_project":
| subject | type | responsible | due_date |
| work_package#1 | Task | bob | 2010/01/15 |
Given the date is "2010/02/01"
And the work_package "work_package#1" is updated with the following:
| type | Story |
| responsible | pamela |
| due_date | 2010/01/20 |
Given the date is "2010/03/01"
And I call the work_package-api on project "sample_project" at time "2010/01/03" and filter for types "Story"
Then the json-response for work_package "work_package#1" should have the due_date "2010/01/15"
And the work package "work_package#1" has the due_date "2010/01/20"

@ -52,21 +52,40 @@ Then(/^the json\-response should( not)? contain a work_package "(.*?)"$/) do |ne
end
And(/^the json\-response for work_package "(.*?)" should have the type "(.*?)"$/) do |work_package_name, type_name|
work_package = lookup_work_package(work_package_name)
expect(work_package["planning_element_type"]["name"]).to eql type_name
end
And(/^the json\-response for work_package "(.*?)" should have the responsible "(.*?)"$/) do |work_package_name, responsible_name|
work_package = lookup_work_package(work_package_name)
expect(work_package["responsible"]["name"]).to eql responsible_name
end
Then(/^the json\-response for work_package "(.*?)" should have the due_date "(.*?)"$/) do |work_package_name, due_date|
work_package = lookup_work_package(work_package_name)
expect(work_package["end_date"]).to eql due_date.gsub('/','-') # normalize the date-format
end
And(/^the json\-response should say that "(.*?)" is parent of "(.*?)"$/) do |parent_name, child_name|
child = decoded_json["planning_elements"].select {|wp| wp["name"] == child_name}.first
child = lookup_work_package(child_name)
expect(child["parent"]["name"]).to eql parent_name
end
And(/^the json\-response should say that "(.*?)" has no parent$/) do |child_name|
child = decoded_json["planning_elements"].select {|wp| wp["name"] == child_name}.first
child = child = lookup_work_package(child_name)
expect(child["parent"]).to be_nil
end
And(/^the json\-response should say that "(.*?)" has (\d+) child(ren)?$/) do |parent_name, nr_of_children,plural|
parent = decoded_json["planning_elements"].select {|wp| wp["name"] == parent_name}.first
parent = child = lookup_work_package(parent_name)
expect(parent["children"].size).to eql nr_of_children.to_i
end
And(/^the work package "(.*?)" has the due_date "(.*?)"$/) do |work_package_name, due_date|
wp = WorkPackage.where(:subject => work_package_name).first
expect(wp.due_date).to eql Date.parse(due_date)
end
When(/^I call the work_package\-api on project "(.*?)" requesting format "(.*?)" filtering for status "(.*?)"$/) do |project_name, format, status_names|
statuses = Status.where(name: status_names.split(','))
@ -101,6 +120,17 @@ When(/^I call the work_package\-api on project "(.*?)" requesting format "(.*?)"
end
And(/^I call the work_package\-api on project "(.*?)" at time "(.*?)" and filter for types "(.*?)"$/) do |project_name, at_time, type_names|
types = Project.find_by_identifier(project_name).types.where(name: type_names.split(','))
get_filtered_json(project_name: project_name,
format: 'json',
filters: [:type_id],
operators: {type_id: '='},
values: {type_id: types.map(&:id)},
at_time: DateTime.parse(at_time).to_i) # the api accepts the time as unix-timestamps(epoch)
end
And(/^there are (\d+) work packages of type "(.*?)" in project "(.*?)"$/) do |nr_of_wps, type_name, project_name|
project = Project.find_by_identifier(project_name)
@ -127,6 +157,10 @@ Then(/^the time to get the filtered results should be faster than the time to ge
@filtered_benchmark.total.should < @unfiltered_benchmark.total
end
def lookup_work_package(work_package_name)
work_package = decoded_json["planning_elements"].select {|wp| wp["name"] == work_package_name}.first
end
def work_package_names
decoded_json["planning_elements"].map{|wp| wp["name"]}
end
@ -145,6 +179,8 @@ def get_filtered_json(params)
format: params[:format],
f: params[:filters],
op: params[:operators],
v: params[:values])
v: params[:values],
at_time: params[:at_time])
end
end

@ -1,60 +0,0 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2013 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.
#++
Given (/^there are the following work packages(?: in project "([^"]*)")?:$/) do |project_name, table|
project = get_project(project_name)
table.map_headers! { |header| header.underscore.gsub(' ', '_') }
table.hashes.each do |type_attributes|
[
["author", User],
["responsible", User],
["assigned_to", User],
["type", Type],
["fixed_version", Version],
["priority", IssuePriority],
["status", Status],
["parent", WorkPackage]
].each do |key, const|
if type_attributes[key].present?
type_attributes[key] = InstanceFinder.find(const, type_attributes[key])
else
type_attributes.delete(key)
end
end
# lookup the type by its name and replace it with the type
# if the cast is ommitted, the contents of type_attributes is interpreted as an int
unless type_attributes.has_key? :type
type_attributes[:type] = Type.where(name: type_attributes[:type].to_s).first
end
factory = FactoryGirl.create(:work_package, type_attributes.merge(:project_id => project.id))
end
end

@ -28,27 +28,47 @@
# See doc/COPYRIGHT.rdoc for more details.
#++
Given /^the time is ([0-9]+) minutes later$/ do |duration|
Given(/^the time is ([0-9]+) minutes earlier$/) do |duration|
Timecop.travel(Time.now - duration.to_i.minutes)
# Ensure timecop returns after each scenario
Support::ResetTimecop.reset_after
end
Given(/^the time is ([0-9]+) days earlier$/) do |duration|
Timecop.travel(Time.now - duration.to_i.days)
# Ensure timecop returns after each scenario
Support::ResetTimecop.reset_after
end
Given(/^the time is ([0-9]+) minutes later$/) do |duration|
Timecop.travel(Time.now + duration.to_i.minutes)
# Ensure timecop returns after each scenario
Support::ResetTimecop.reset_after
end
Given /^the time is ([0-9]+) days later$/ do |duration|
Given(/^the time is ([0-9]+) days later$/) do |duration|
Timecop.travel(Time.now + duration.to_i.days)
# Ensure timecop returns after each scenario
Support::ResetTimecop.reset_after
end
Given(/^the date is "(.*?)"$/) do |date|
new_time = Time.parse date
Timecop.travel(new_time)
# Ensure timecop returns after each scenario
Support::ResetTimecop.reset_after
end
module Support
module ResetTimecop
def self.reset_after
Support::Cleanup.to_clean do
Proc.new do
Timecop.return
end
Timecop.return
end
end
end

@ -0,0 +1,94 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2013 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.
#++
Given(/^there are the following work packages were added "(.*?)"(?: in project "([^"]*)")?:$/) do |time, project_name, table|
project = get_project(project_name)
#TODO provide better time support with some gem that can parse this:
case time
when "three weeks ago"
target_time = 3.weeks.ago
else
target_time = Time.now
end
Timecop.travel(target_time)
create_work_packages_from_table table, project
# Ensure timecop returns after each scenario
Support::ResetTimecop.reset_after
end
Given(/^the work package "(.*?)" was changed "(.*?)" to:$/) do |name, time, table|
table.map_headers! { |header| header.underscore.gsub(' ', '_') }
#TODO provide better time support with some gem that can parse this:
case time
when "one week ago"
target_time = 1.weeks.ago
when "two weeks ago"
target_time = 2.weeks.ago
else
target_time = Time.now
end
Timecop.travel(target_time)
#TODO provide generic support for all possible values.
work_package = WorkPackage.find_by_subject(name)
work_package.subject = table.hashes.first[:subject]
work_package.start_date = table.hashes.first[:start_date]
work_package.due_date = table.hashes.first[:due_date]
work_package.save!
# Ensure timecop returns after each scenario
Support::ResetTimecop.reset_after
end
When(/^I set the timeline to compare "now" to "(.*?) days ago"$/) do |time|
steps %Q{
When I edit the settings of the current timeline
}
page.should have_selector("#timeline_options_vertical_planning_elements", :visible => false)
page.execute_script("jQuery('#timeline_options_comparison_relative').attr('checked', 'checked')")
page.execute_script("jQuery('#timeline_options_compare_to_relative').val('#{time}')")
page.execute_script("jQuery('#timeline_options_compare_to_relative_unit').val(0)")
page.execute_script("jQuery('#content form').submit()")
end
Then(/^I should see the work package "(.*?)" has not moved$/) do |arg1|
pending # express the regexp above with the code you wish you had
end
Then(/^I should see the work package "(.*?)" has moved$/) do |arg1|
pending # express the regexp above with the code you wish you had
end
Then(/^I should not see the work package "(.*?)"$/) do |arg1|
pending # express the regexp above with the code you wish you had
end

@ -118,3 +118,38 @@ Given /^the following types are enabled for projects of type "(.*?)"$/ do |proje
project.save
end
end
Given (/^there are the following work packages(?: in project "([^"]*)")?:$/) do |project_name, table|
project = get_project(project_name)
create_work_packages_from_table table, project
end
def create_work_packages_from_table table, project
table.map_headers! { |header| header.underscore.gsub(' ', '_') }
table.hashes.each do |type_attributes|
[ ["author", User],
["responsible", User],
["assigned_to", User],
["type", Type],
["fixed_version", Version],
["priority", IssuePriority],
["status", Status],
["parent", WorkPackage]
].each do |key, const|
if type_attributes[key].present?
type_attributes[key] = InstanceFinder.find(const, type_attributes[key])
else
type_attributes.delete(key)
end
end
# lookup the type by its name and replace it with the type
# if the cast is ommitted, the contents of type_attributes is interpreted as an int
unless type_attributes.has_key? :type
type_attributes[:type] = Type.where(name: type_attributes[:type].to_s).first
end
FactoryGirl.create(:work_package, type_attributes.merge(:project_id => project.id))
end
end

@ -1,5 +1,4 @@
# encoding: utf-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2013 the OpenProject Foundation (OPF)
@ -61,8 +60,13 @@ end
Given(/^the work_package "(.+?)" is updated with the following:$/) do |subject, table|
work_package = WorkPackage.find_by_subject(subject)
except = {}
except["type"] = lambda{|wp, value| wp.type = Type.find_by_name(value) if value }
except["assigned_to"] = lambda{|wp, value| wp.assigned_to = User.find_by_login(value) if value}
except["responsible"] = lambda{|wp, value| wp.responsible = User.find_by_login(value) if value}
send_table_to_object(work_package, table)
send_table_to_object(work_package, table, except)
end
When /^I fill in the id of work package "(.+?)" into "(.+?)"$/ do |wp_name, field_name|

@ -0,0 +1,81 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2013 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.
#++
Feature: Timeline Comparison View Tests
Background:
Given there is 1 user with:
| login | manager |
And there is a role "manager"
And the role "manager" may have the following rights:
| view_timelines |
| edit_timelines |
| view_work_packages |
| edit_work_packages |
| delete_work_packages |
| view_reportings |
| view_project_associations |
And there is a project named "Volatile Planning"
And I am working in project "Volatile Planning"
And the project uses the following modules:
| timelines |
And the user "manager" is a "manager"
And I am already logged in as "manager"
And there are the following work packages were added "three weeks ago":
| Subject | Start date | Due date |
| January | 2014-01-01 | 2014-01-31 |
| February | 2014-02-01 | 2014-02-28 |
| March | 2014-03-01 | 2014-03-31 |
| April | 2014-04-01 | 2014-04-30 |
And the work package "February" was changed "two weeks ago" to:
| Subject | Start date | Due date |
| May | 2014-05-01 | 2014-05-31 |
And the work package "January" was changed "one week ago" to:
| Subject | Start date | Due date |
| February | 2014-02-01 | 2014-02-28 |
@javascript
Scenario: nine days comparison
Given I am working in the timeline "Changes" of the project called "Volatile Planning"
When there is a timeline "Changes" for project "Volatile Planning"
And I set the timeline to compare "now" to "9 days ago"
And I go to the page of the timeline "Changes" of the project called "Volatile Planning"
And I wait for timeline to load table
Then I should see the work package "May" has not moved
And I should see the work package "February" has moved
And I should not see the work package "January"
@javascript
Scenario: sixteen days comparison
Given I am working in the timeline "Changes" of the project called "Volatile Planning"
When there is a timeline "Changes" for project "Volatile Planning"
And I set the timeline to compare "now" to "16 days ago"
And I go to the page of the timeline "Changes" of the project called "Volatile Planning"
And I wait for timeline to load table
Then I should see the work package "May" has moved
And I should see the work package "February" has moved
And I should not see the work package "January"

@ -29,9 +29,9 @@ Feature: Timeline View Tests
And there is a role "manager"
And the role "manager" may have the following rights:
| view_timelines |
| edit_timelines |
| view_work_packages |
| view_timelines |
| edit_timelines |
| view_work_packages |
And there is a project named "ecookbook" of type "Standard Project"
And I am working in project "ecookbook"
@ -48,16 +48,16 @@ Feature: Timeline View Tests
And I am already logged in as "manager"
And there are the following work packages:
| Start date | Due date | description | responsible | Subject |
| 2012-01-01 | 2012-01-31 | #2 http://google.de | manager | January |
| 2012-02-01 | 2012-02-24 | Avocado Rincon | manager | February |
| 2012-03-01 | 2012-03-30 | Hass | manager | March |
| 2012-04-01 | 2012-04-30 | Avocado Choquette | manager | April |
| 2012-04-01 | 2012-04-30 | Relish | manager | Test2 |
| Start date | Due date | description | responsible | Subject |
| 2012-01-01 | 2012-01-31 | #2 http://google.de | manager | January |
| 2012-02-01 | 2012-02-24 | Avocado Rincon | manager | February |
| 2012-03-01 | 2012-03-30 | Hass | manager | March |
| 2012-04-01 | 2012-04-30 | Avocado Choquette | manager | April |
| 2012-04-01 | 2012-04-30 | Relish | manager | Test2 |
@javascript
Scenario: planning element click should show modal window
When there is a timeline "Testline" for project "ecookbook"
When there is a timeline "Testline" for project "ecookbook"
And I go to the page of the timeline "Testline" of the project called "ecookbook"
And I wait for timeline to load table
And I click on the Planning Element with name "January"
@ -68,6 +68,6 @@ Feature: Timeline View Tests
And I should see "01/31/2012" in the modal
And I should see "New timeline report"
And I should be on the page of the timeline "Testline" of the project called "ecookbook"
When I ctrl-click on "#2" in the modal
Then I should see "February" in the new window
Then I should see "Avocado Rincon" in the new window
When I ctrl-click on "#2" in the modal
Then I should see "February" in the new window
Then I should see "Avocado Rincon" in the new window

@ -69,9 +69,9 @@ Feature: Timeline View Tests
Given I am working in the timeline "Testline" of the project called "ecookbook"
When there is a timeline "Testline" for project "ecookbook"
And I set the columns shown in the timeline to:
| start_date |
| type |
| end_date |
| start_date |
| type |
| end_date |
Then I should see the column "Start date" before the column "End date" in the timelines table
And I should see the column "Start date" before the column "Type" in the timelines table
And I should see the column "Type" before the column "End date" in the timelines table

@ -83,14 +83,14 @@ Feature: Timeline Wiki Macro
And the user "mrtimeline" is a "god"
And there are the following planning element statuses:
| Name |
| closed |
| Name |
| closed |
And there are the following work packages:
| Subject | Start date | Due date | description | status | responsible |
| January | 2012-01-01 | 2012-01-31 | Avocado Grande | closed | manager |
| February | 2012-02-01 | 2012-02-24 | Avocado Sali | closed | manager |
| March | 2012-03-01 | 2012-03-30 | Sali Grande | closed | manager |
| April | 2012-04-01 | 2012-04-30 | Avocado Sali Grande | closed | manager |
| Subject | Start date | Due date | description | status | responsible |
| January | 2012-01-01 | 2012-01-31 | Avocado Grande | closed | manager |
| February | 2012-02-01 | 2012-02-24 | Avocado Sali | closed | manager |
| March | 2012-03-01 | 2012-03-30 | Sali Grande | closed | manager |
| April | 2012-04-01 | 2012-04-30 | Avocado Sali Grande | closed | manager |
And there is a timeline "Testline" for project "ecookbook"
@javascript

@ -0,0 +1,148 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2013 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.
#++
require 'spec_helper'
describe "Planning Comparison" do
let (:project){FactoryGirl.create(:project)}
let (:admin) {FactoryGirl.create(:admin)}
before do
# query implicitly uses the logged in user to check for allowed work_packages/projects
User.stub(:current).and_return(admin)
end
describe "going back in history" do
let(:journalized_work_package) do
#TODO are these actually unit-tests?!
wp = nil
# create 2 journal-entries, to make sure, that the comparison actually picks up the latest one
Timecop.travel(2.weeks.ago) do
wp = FactoryGirl.create(:work_package, project: project, start_date: "01/01/2020", due_date: "01/03/2020")
wp.save # triggers the journaling and saves the old due_date, creating the baseline for the comparison
end
Timecop.travel(1.week.ago) do
wp.reload
wp.due_date = "01/04/2020"
wp.save # triggers the journaling and saves the old due_date, creating the baseline for the comparison
end
wp.reload
wp.due_date = "01/05/2020"
wp.save # adds another journal-entry
wp
end
before { wp = journalized_work_package }
it "should return the changes as a work_package" do
# beware of these date-conversions: 1.week.ago does not catch the change, as created_at is stored as a timestamp
expect(PlanningComparisonService.compare(project, 5.days.ago).size).to eql 1
expect(PlanningComparisonService.compare(project, 5.days.ago).first).to be_instance_of WorkPackage
end
it "should return the old due_date in the comparison" do
# beware of these date-conversions: 1.week.ago does not catch the change, as created_at is stored as a timestamp
old_work_package = PlanningComparisonService.compare(project, 5.days.ago).first
expect(old_work_package.due_date).to eql Date.parse "01/04/2020"
end
it "should return only the latest change when the workpackage was edited on the same day more than once" do
Timecop.travel(1.week.ago) do
journalized_work_package.reload
journalized_work_package.due_date = "01/05/2020"
journalized_work_package.save # triggers the journaling and saves the old due_date, creating the baseline for the comparison
journalized_work_package.reload
journalized_work_package.due_date = "01/07/2020"
journalized_work_package.save
end
old_work_packages = PlanningComparisonService.compare(project, 5.days.ago)
expect(old_work_packages.size).to eql 1
expect(old_work_packages.first.due_date).to eql Date.parse "01/07/2020"
end
end
describe "filtering work_packages also applies to the history" do
let(:assigned_to_user) {FactoryGirl.create(:user)}
let (:filter) do
{ f: ["assigned_to_id"],
op: {"assigned_to_id" => "="},
v: {"assigned_to_id" => ["#{assigned_to_user.id}"]} }
end
let (:work_package) do
wp = nil
# create 2 journal-entries, to make sure, that the comparison actually picks up the latest one
Timecop.travel(1.week.ago) do
wp = FactoryGirl.create(:work_package, project: project, due_date: "01/03/2020", assigned_to_id: assigned_to_user.id)
wp.save # triggers the journaling and saves the old due_date, creating the baseline for the comparison
end
wp.reload
wp.due_date = "01/05/2020"
wp.save # adds another journal-entry
wp
end
let (:filtered_work_package) do
other_user = FactoryGirl.create(:user)
wp = nil
# create 2 journal-entries, to make sure, that the comparison actually picks up the latest one
Timecop.travel(1.week.ago) do
wp = FactoryGirl.create(:work_package, project: project, due_date: "01/03/2020", assigned_to_id: other_user.id)
wp.save # triggers the journaling and saves the old due_date, creating the baseline for the comparison
end
wp.reload
wp.due_date = "01/05/2020"
wp.save # adds another journal-entry
wp
end
before do
work_package
filtered_work_package
end
it "should filter out the work_package assigned to the wrong person" do
filtered_packages = PlanningComparisonService.compare(project, 5.days.ago, filter)
expect(filtered_packages).to include work_package
expect(filtered_packages).not_to include filtered_work_package
end
end
end
Loading…
Cancel
Save