Merge pull request #478 from opf/feature/planning_comparison
commit
af20ca70c7
@ -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 |
@ -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 |
@ -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 |
@ -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" |
@ -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…
Reference in new issue