diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index f3280cc23..e36d9bfdf 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -231,6 +231,24 @@ class ProjectsController < ApplicationController
end
@versions = @project.versions.sort
end
+
+ def save_activities
+ if request.post? && params[:enumerations]
+ params[:enumerations].each do |id, activity|
+ @project.update_or_build_time_entry_activity(id, activity)
+ end
+ @project.save
+ end
+
+ redirect_to :controller => 'projects', :action => 'settings', :tab => 'activities', :id => @project
+ end
+
+ def reset_activities
+ @project.time_entry_activities.each do |time_entry_activity|
+ time_entry_activity.destroy
+ end
+ redirect_to :controller => 'projects', :action => 'settings', :tab => 'activities', :id => @project
+ end
def list_files
sort_init 'filename', 'asc'
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 912450c1c..d68cb9bbb 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -29,7 +29,8 @@ module ProjectsHelper
{:name => 'categories', :action => :manage_categories, :partial => 'projects/settings/issue_categories', :label => :label_issue_category_plural},
{:name => 'wiki', :action => :manage_wiki, :partial => 'projects/settings/wiki', :label => :label_wiki},
{:name => 'repository', :action => :manage_repository, :partial => 'projects/settings/repository', :label => :label_repository},
- {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural}
+ {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural},
+ {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :enumeration_activities}
]
tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
end
diff --git a/app/models/enumeration.rb b/app/models/enumeration.rb
index bdb6ddd25..f219a4c7c 100644
--- a/app/models/enumeration.rb
+++ b/app/models/enumeration.rb
@@ -25,7 +25,7 @@ class Enumeration < ActiveRecord::Base
before_destroy :check_integrity
validates_presence_of :name
- validates_uniqueness_of :name, :scope => [:type]
+ validates_uniqueness_of :name, :scope => [:type, :project_id]
validates_length_of :name, :maximum => 30
# Backwards compatiblity named_scopes.
@@ -58,7 +58,7 @@ class Enumeration < ActiveRecord::Base
end
# End backwards compatiblity named_scopes
- named_scope :all, :order => 'position'
+ named_scope :all, :order => 'position', :conditions => { :project_id => nil }
named_scope :active, lambda {
{
@@ -134,6 +134,32 @@ class Enumeration < ActiveRecord::Base
def self.get_subclasses
@@subclasses[Enumeration]
end
+
+ # Does the +new+ Hash override the previous Enumeration?
+ def self.overridding_change?(new, previous)
+ if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
+ return false
+ else
+ return true
+ end
+ end
+
+ # Does the +new+ Hash have the same custom values as the previous Enumeration?
+ def self.same_custom_values?(new, previous)
+ previous.custom_field_values.each do |custom_value|
+ if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
+ return false
+ end
+ end
+
+ return true
+ end
+
+ # Are the new and previous fields equal?
+ def self.same_active_state?(new, previous)
+ new = (new == "1" ? true : false)
+ return new == previous
+ end
private
def check_integrity
diff --git a/app/models/project.rb b/app/models/project.rb
index 6e397b776..db8c6711e 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -20,7 +20,12 @@ class Project < ActiveRecord::Base
STATUS_ACTIVE = 1
STATUS_ARCHIVED = 9
- has_many :time_entry_activities, :conditions => {:active => true } # Specific overidden Activities
+ # Specific overidden Activities
+ has_many :time_entry_activities do
+ def active
+ find(:all, :conditions => {:active => true})
+ end
+ end
has_many :members, :include => :user, :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
has_many :member_principals, :class_name => 'Member',
:include => :principal,
@@ -156,14 +161,37 @@ class Project < ActiveRecord::Base
statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
end
- # Returns all the Systemwide and project specific activities
- def activities
- overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
-
- if overridden_activity_ids.empty?
- return TimeEntryActivity.active
+ # Returns the Systemwide and project specific activities
+ def activities(include_inactive=false)
+ if include_inactive
+ return all_activities
else
- return system_activities_and_project_overrides
+ return active_activities
+ end
+ end
+
+ # Will build a new Project specific Activity or update an existing one
+ def update_or_build_time_entry_activity(id, activity_hash)
+ if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
+ self.build_time_entry_activity_if_needed(activity_hash)
+ else
+ activity = project.time_entry_activities.find_by_id(id.to_i)
+ activity.update_attributes(activity_hash) if activity
+ end
+ end
+
+ # Builds new activity
+ def build_time_entry_activity_if_needed(activity)
+ # Only new override activities are built
+ if activity['parent_id']
+
+ parent_activity = TimeEntryActivity.find(activity['parent_id'])
+ activity['name'] = parent_activity.name
+ activity['position'] = parent_activity.position
+
+ if Enumeration.overridding_change?(activity, parent_activity)
+ self.time_entry_activities.build(activity)
+ end
end
end
@@ -459,11 +487,41 @@ private
@actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
end
- # Returns the systemwide activities merged with the project specific overrides
- def system_activities_and_project_overrides
- return TimeEntryActivity.active.
- find(:all,
- :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
- self.time_entry_activities
+ # Returns all the active Systemwide and project specific activities
+ def active_activities
+ overridden_activity_ids = self.time_entry_activities.active.collect(&:parent_id)
+
+ if overridden_activity_ids.empty?
+ return TimeEntryActivity.active
+ else
+ return system_activities_and_project_overrides
+ end
+ end
+
+ # Returns all the Systemwide and project specific activities
+ # (inactive and active)
+ def all_activities
+ overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
+
+ if overridden_activity_ids.empty?
+ return TimeEntryActivity.all
+ else
+ return system_activities_and_project_overrides(true)
+ end
+ end
+
+ # Returns the systemwide active activities merged with the project specific overrides
+ def system_activities_and_project_overrides(include_inactive=false)
+ if include_inactive
+ return TimeEntryActivity.all.
+ find(:all,
+ :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
+ self.time_entry_activities
+ else
+ return TimeEntryActivity.active.
+ find(:all,
+ :conditions => ["id NOT IN (?)", self.time_entry_activities.active.collect(&:parent_id)]) +
+ self.time_entry_activities.active
+ end
end
end
diff --git a/app/views/projects/settings/_activities.rhtml b/app/views/projects/settings/_activities.rhtml
new file mode 100644
index 000000000..36b740d38
--- /dev/null
+++ b/app/views/projects/settings/_activities.rhtml
@@ -0,0 +1,37 @@
+<% form_tag({:controller => 'projects', :action => 'save_activities', :id => @project}, :class => "tabular") do %>
+
+
+
+ <%= l(:field_name) %> |
+ <%= l(:enumeration_system_activity) %> |
+ <% TimeEntryActivity.new.available_custom_fields.each do |value| %>
+ <%= h value.name %> |
+ <% end %>
+ <%= l(:field_active) %> |
+
+
+ <% @project.activities(true).each do |enumeration| %>
+ <% fields_for "enumerations[#{enumeration.id}]", enumeration do |ff| %>
+
+
+ <%= ff.hidden_field :parent_id, :value => enumeration.id unless enumeration.project %>
+ <%= h(enumeration) %>
+ |
+ <%= image_tag('true.png') unless enumeration.project %> |
+ <% enumeration.custom_field_values.each do |value| %>
+
+ <%= custom_field_tag "enumerations[#{enumeration.id}]", value %>
+ |
+ <% end %>
+
+ <%= ff.check_box :active %>
+ |
+
+ <% end %>
+ <% end %>
+
+
+<%= submit_tag l(:button_save) %>
+<% end %>
+<%= button_to l(:button_reset), {:controller => 'projects', :action => 'reset_activities', :id => @project}, :method => :delete, :confirm => l(:text_are_you_sure), :style => "position: relative; top: -20px; left: 60px;" %>
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8a07c644a..936692926 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -830,3 +830,4 @@ en:
enumeration_issue_priorities: Issue priorities
enumeration_doc_categories: Document categories
enumeration_activities: Activities (time tracking)
+ enumeration_system_activity: System Activity
diff --git a/config/routes.rb b/config/routes.rb
index 3b790af14..f3c25a791 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -200,6 +200,11 @@ ActionController::Routing::Routes.draw do |map|
project_actions.connect 'projects/:id/files/new', :action => 'add_file'
project_actions.connect 'projects/:id/versions/new', :action => 'add_version'
project_actions.connect 'projects/:id/categories/new', :action => 'add_issue_category'
+ project_actions.connect 'projects/:id/activities/save', :action => 'save_activities'
+ end
+
+ projects.with_options :conditions => {:method => :delete} do |project_actions|
+ project_actions.conditions 'projects/:id/reset_activities', :action => 'reset_activities'
end
end
diff --git a/lib/redmine.rb b/lib/redmine.rb
index 76518802f..ed227fddf 100644
--- a/lib/redmine.rb
+++ b/lib/redmine.rb
@@ -59,6 +59,7 @@ Redmine::AccessControl.map do |map|
map.permission :view_time_entries, :timelog => [:details, :report]
map.permission :edit_time_entries, {:timelog => [:edit, :destroy]}, :require => :member
map.permission :edit_own_time_entries, {:timelog => [:edit, :destroy]}, :require => :loggedin
+ map.permission :manage_project_activities, {:projects => [:save_activities, :reset_activities]}, :require => :member
end
map.project_module :news do |map|
diff --git a/test/fixtures/custom_values.yml b/test/fixtures/custom_values.yml
index 4e6f4b3ff..155191bec 100644
--- a/test/fixtures/custom_values.yml
+++ b/test/fixtures/custom_values.yml
@@ -89,3 +89,9 @@ custom_values_015:
customized_id: 10
id: 15
value: true
+custom_values_016:
+ customized_type: Enumeration
+ custom_field_id: 7
+ customized_id: 11
+ id: 16
+ value: true
diff --git a/test/fixtures/roles.yml b/test/fixtures/roles.yml
index b8881fa4d..64457c532 100644
--- a/test/fixtures/roles.yml
+++ b/test/fixtures/roles.yml
@@ -46,6 +46,7 @@ roles_001:
- :browse_repository
- :manage_repository
- :view_changesets
+ - :manage_project_activities
position: 1
roles_002:
@@ -174,4 +175,4 @@ roles_005:
- :view_changesets
position: 5
-
\ No newline at end of file
+
diff --git a/test/functional/projects_controller_test.rb b/test/functional/projects_controller_test.rb
index 8209c85f7..9736ce056 100644
--- a/test/functional/projects_controller_test.rb
+++ b/test/functional/projects_controller_test.rb
@@ -24,7 +24,7 @@ class ProjectsController; def rescue_action(e) raise e end; end
class ProjectsControllerTest < ActionController::TestCase
fixtures :projects, :versions, :users, :roles, :members, :member_roles, :issues, :journals, :journal_details,
:trackers, :projects_trackers, :issue_statuses, :enabled_modules, :enumerations, :boards, :messages,
- :attachments
+ :attachments, :custom_fields, :custom_values
def setup
@controller = ProjectsController.new
@@ -555,7 +555,134 @@ class ProjectsControllerTest < ActionController::TestCase
assert_response :success
assert_template 'show'
end
+
+ def test_reset_activities_routing
+ assert_routing({:method => :delete, :path => 'projects/64/reset_activities'},
+ :controller => 'projects', :action => 'reset_activities', :id => '64')
+ end
+
+ def test_reset_activities
+ @request.session[:user_id] = 2 # manager
+ project_activity = TimeEntryActivity.new({
+ :name => 'Project Specific',
+ :parent => TimeEntryActivity.find(:first),
+ :project => Project.find(1),
+ :active => true
+ })
+ assert project_activity.save
+ project_activity_two = TimeEntryActivity.new({
+ :name => 'Project Specific Two',
+ :parent => TimeEntryActivity.find(:last),
+ :project => Project.find(1),
+ :active => true
+ })
+ assert project_activity_two.save
+
+ delete :reset_activities, :id => 1
+ assert_response :redirect
+ assert_redirected_to 'projects/ecookbook/settings/activities'
+
+ assert_nil TimeEntryActivity.find_by_id(project_activity.id)
+ assert_nil TimeEntryActivity.find_by_id(project_activity_two.id)
+ end
+ def test_save_activities_routing
+ assert_routing({:method => :post, :path => 'projects/64/activities/save'},
+ :controller => 'projects', :action => 'save_activities', :id => '64')
+ end
+
+ def test_save_activities_to_override_system_activities
+ @request.session[:user_id] = 2 # manager
+ billable_field = TimeEntryActivityCustomField.find_by_name("Billable")
+
+ post :save_activities, :id => 1, :enumerations => {
+ "9"=> {"parent_id"=>"9", "custom_field_values"=>{"7" => "1"}, "active"=>"0"}, # Design, De-activate
+ "10"=> {"parent_id"=>"10", "custom_field_values"=>{"7"=>"0"}, "active"=>"1"}, # Development, Change custom value
+ "14"=>{"parent_id"=>"14", "custom_field_values"=>{"7"=>"1"}, "active"=>"1"}, # Inactive Activity, Activate with custom value
+ "11"=>{"parent_id"=>"11", "custom_field_values"=>{"7"=>"1"}, "active"=>"1"} # QA, no changes
+ }
+
+ assert_response :redirect
+ assert_redirected_to 'projects/ecookbook/settings/activities'
+
+ # Created project specific activities...
+ project = Project.find('ecookbook')
+
+ # ... Design
+ design = project.time_entry_activities.find_by_name("Design")
+ assert design, "Project activity not found"
+
+ assert_equal 9, design.parent_id # Relate to the system activity
+ assert_not_equal design.parent.id, design.id # Different records
+ assert_equal design.parent.name, design.name # Same name
+ assert !design.active?
+
+ # ... Development
+ development = project.time_entry_activities.find_by_name("Development")
+ assert development, "Project activity not found"
+
+ assert_equal 10, development.parent_id # Relate to the system activity
+ assert_not_equal development.parent.id, development.id # Different records
+ assert_equal development.parent.name, development.name # Same name
+ assert development.active?
+ assert_equal "0", development.custom_value_for(billable_field).value
+
+ # ... Inactive Activity
+ previously_inactive = project.time_entry_activities.find_by_name("Inactive Activity")
+ assert previously_inactive, "Project activity not found"
+
+ assert_equal 14, previously_inactive.parent_id # Relate to the system activity
+ assert_not_equal previously_inactive.parent.id, previously_inactive.id # Different records
+ assert_equal previously_inactive.parent.name, previously_inactive.name # Same name
+ assert previously_inactive.active?
+ assert_equal "1", previously_inactive.custom_value_for(billable_field).value
+
+ # ... QA
+ assert_equal nil, project.time_entry_activities.find_by_name("QA"), "Custom QA activity created when it wasn't modified"
+ end
+
+ def test_save_activities_will_update_project_specific_activities
+ @request.session[:user_id] = 2 # manager
+
+ project_activity = TimeEntryActivity.new({
+ :name => 'Project Specific',
+ :parent => TimeEntryActivity.find(:first),
+ :project => Project.find(1),
+ :active => true
+ })
+ assert project_activity.save
+ project_activity_two = TimeEntryActivity.new({
+ :name => 'Project Specific Two',
+ :parent => TimeEntryActivity.find(:last),
+ :project => Project.find(1),
+ :active => true
+ })
+ assert project_activity_two.save
+
+
+ post :save_activities, :id => 1, :enumerations => {
+ project_activity.id => {"custom_field_values"=>{"7" => "1"}, "active"=>"0"}, # De-activate
+ project_activity_two.id => {"custom_field_values"=>{"7" => "1"}, "active"=>"0"} # De-activate
+ }
+
+ assert_response :redirect
+ assert_redirected_to 'projects/ecookbook/settings/activities'
+
+ # Created project specific activities...
+ project = Project.find('ecookbook')
+ assert_equal 2, project.time_entry_activities.count
+
+ activity_one = project.time_entry_activities.find_by_name(project_activity.name)
+ assert activity_one, "Project activity not found"
+ assert_equal project_activity.id, activity_one.id
+ assert !activity_one.active?
+
+ activity_two = project.time_entry_activities.find_by_name(project_activity_two.name)
+ assert activity_two, "Project activity not found"
+ assert_equal project_activity_two.id, activity_two.id
+ assert !activity_two.active?
+ end
+
# A hook that is manually registered later
class ProjectBasedTemplate < Redmine::Hook::ViewListener
def view_layouts_base_html_head(context)
diff --git a/test/unit/project_test.rb b/test/unit/project_test.rb
index 7ac9bf382..ad483a23c 100644
--- a/test/unit/project_test.rb
+++ b/test/unit/project_test.rb
@@ -344,10 +344,17 @@ class ProjectTest < ActiveSupport::TestCase
end
def test_activities_should_handle_nils
+ overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.find(:first)})
TimeEntryActivity.delete_all
+ # No activities
project = Project.find(1)
assert project.activities.empty?
+
+ # No system, one overridden
+ assert overridden_activity.save!
+ project.reload
+ assert_equal [overridden_activity], project.activities
end
def test_activities_should_override_system_activities_with_project_activities
@@ -360,6 +367,14 @@ class ProjectTest < ActiveSupport::TestCase
assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
end
+ def test_activities_should_include_inactive_activities_if_specified
+ project = Project.find(1)
+ overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
+ assert overridden_activity.save!
+
+ assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
+ end
+
context "Project#copy" do
setup do
ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests