diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index f3280cc2..e36d9bfd 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 912450c1..d68cb9bb 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 bdb6ddd2..f219a4c7 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 6e397b77..db8c6711 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 00000000..36b740d3 --- /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 %> + + + + + + <% TimeEntryActivity.new.available_custom_fields.each do |value| %> + + <% end %> + + + + <% @project.activities(true).each do |enumeration| %> + <% fields_for "enumerations[#{enumeration.id}]", enumeration do |ff| %> + + + + <% enumeration.custom_field_values.each do |value| %> + + <% end %> + + + <% end %> + <% end %> +
<%= l(:field_name) %><%= l(:enumeration_system_activity) %><%= h value.name %><%= l(:field_active) %>
+ <%= ff.hidden_field :parent_id, :value => enumeration.id unless enumeration.project %> + <%= h(enumeration) %> + <%= image_tag('true.png') unless enumeration.project %> + <%= custom_field_tag "enumerations[#{enumeration.id}]", value %> + + <%= ff.check_box :active %> +
+ +<%= 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 8a07c644..93669292 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 3b790af1..f3c25a79 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 76518802..ed227fdd 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 4e6f4b3f..155191be 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 b8881fa4..64457c53 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 8209c85f..9736ce05 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 7ac9bf38..ad483a23 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