##// END OF EJS Templates
Use .distinct instead of .uniq....
Jean-Philippe Lang -
r15272:d2f7e31951d0
parent child
Show More
@@ -1,183 +1,183
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class VersionsController < ApplicationController
18 class VersionsController < ApplicationController
19 menu_item :roadmap
19 menu_item :roadmap
20 model_object Version
20 model_object Version
21 before_filter :find_model_object, :except => [:index, :new, :create, :close_completed]
21 before_filter :find_model_object, :except => [:index, :new, :create, :close_completed]
22 before_filter :find_project_from_association, :except => [:index, :new, :create, :close_completed]
22 before_filter :find_project_from_association, :except => [:index, :new, :create, :close_completed]
23 before_filter :find_project_by_project_id, :only => [:index, :new, :create, :close_completed]
23 before_filter :find_project_by_project_id, :only => [:index, :new, :create, :close_completed]
24 before_filter :authorize
24 before_filter :authorize
25
25
26 accept_api_auth :index, :show, :create, :update, :destroy
26 accept_api_auth :index, :show, :create, :update, :destroy
27
27
28 helper :custom_fields
28 helper :custom_fields
29 helper :projects
29 helper :projects
30
30
31 def index
31 def index
32 respond_to do |format|
32 respond_to do |format|
33 format.html {
33 format.html {
34 @trackers = @project.trackers.sorted.to_a
34 @trackers = @project.trackers.sorted.to_a
35 retrieve_selected_tracker_ids(@trackers, @trackers.select {|t| t.is_in_roadmap?})
35 retrieve_selected_tracker_ids(@trackers, @trackers.select {|t| t.is_in_roadmap?})
36 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
36 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
37 project_ids = @with_subprojects ? @project.self_and_descendants.collect(&:id) : [@project.id]
37 project_ids = @with_subprojects ? @project.self_and_descendants.collect(&:id) : [@project.id]
38
38
39 @versions = @project.shared_versions.preload(:custom_values)
39 @versions = @project.shared_versions.preload(:custom_values)
40 @versions += @project.rolled_up_versions.visible.preload(:custom_values) if @with_subprojects
40 @versions += @project.rolled_up_versions.visible.preload(:custom_values) if @with_subprojects
41 @versions = @versions.uniq.sort
41 @versions = @versions.to_a.uniq.sort
42 unless params[:completed]
42 unless params[:completed]
43 @completed_versions = @versions.select(&:completed?)
43 @completed_versions = @versions.select(&:completed?)
44 @versions -= @completed_versions
44 @versions -= @completed_versions
45 end
45 end
46
46
47 @issues_by_version = {}
47 @issues_by_version = {}
48 if @selected_tracker_ids.any? && @versions.any?
48 if @selected_tracker_ids.any? && @versions.any?
49 issues = Issue.visible.
49 issues = Issue.visible.
50 includes(:project, :tracker).
50 includes(:project, :tracker).
51 preload(:status, :priority, :fixed_version).
51 preload(:status, :priority, :fixed_version).
52 where(:tracker_id => @selected_tracker_ids, :project_id => project_ids, :fixed_version_id => @versions.map(&:id)).
52 where(:tracker_id => @selected_tracker_ids, :project_id => project_ids, :fixed_version_id => @versions.map(&:id)).
53 order("#{Project.table_name}.lft, #{Tracker.table_name}.position, #{Issue.table_name}.id")
53 order("#{Project.table_name}.lft, #{Tracker.table_name}.position, #{Issue.table_name}.id")
54 @issues_by_version = issues.group_by(&:fixed_version)
54 @issues_by_version = issues.group_by(&:fixed_version)
55 end
55 end
56 @versions.reject! {|version| !project_ids.include?(version.project_id) && @issues_by_version[version].blank?}
56 @versions.reject! {|version| !project_ids.include?(version.project_id) && @issues_by_version[version].blank?}
57 }
57 }
58 format.api {
58 format.api {
59 @versions = @project.shared_versions.to_a
59 @versions = @project.shared_versions.to_a
60 }
60 }
61 end
61 end
62 end
62 end
63
63
64 def show
64 def show
65 respond_to do |format|
65 respond_to do |format|
66 format.html {
66 format.html {
67 @issues = @version.fixed_issues.visible.
67 @issues = @version.fixed_issues.visible.
68 includes(:status, :tracker, :priority).
68 includes(:status, :tracker, :priority).
69 preload(:project).
69 preload(:project).
70 reorder("#{Tracker.table_name}.position, #{Issue.table_name}.id").
70 reorder("#{Tracker.table_name}.position, #{Issue.table_name}.id").
71 to_a
71 to_a
72 }
72 }
73 format.api
73 format.api
74 end
74 end
75 end
75 end
76
76
77 def new
77 def new
78 @version = @project.versions.build
78 @version = @project.versions.build
79 @version.safe_attributes = params[:version]
79 @version.safe_attributes = params[:version]
80
80
81 respond_to do |format|
81 respond_to do |format|
82 format.html
82 format.html
83 format.js
83 format.js
84 end
84 end
85 end
85 end
86
86
87 def create
87 def create
88 @version = @project.versions.build
88 @version = @project.versions.build
89 if params[:version]
89 if params[:version]
90 attributes = params[:version].dup
90 attributes = params[:version].dup
91 attributes.delete('sharing') unless attributes.nil? || @version.allowed_sharings.include?(attributes['sharing'])
91 attributes.delete('sharing') unless attributes.nil? || @version.allowed_sharings.include?(attributes['sharing'])
92 @version.safe_attributes = attributes
92 @version.safe_attributes = attributes
93 end
93 end
94
94
95 if request.post?
95 if request.post?
96 if @version.save
96 if @version.save
97 respond_to do |format|
97 respond_to do |format|
98 format.html do
98 format.html do
99 flash[:notice] = l(:notice_successful_create)
99 flash[:notice] = l(:notice_successful_create)
100 redirect_back_or_default settings_project_path(@project, :tab => 'versions')
100 redirect_back_or_default settings_project_path(@project, :tab => 'versions')
101 end
101 end
102 format.js
102 format.js
103 format.api do
103 format.api do
104 render :action => 'show', :status => :created, :location => version_url(@version)
104 render :action => 'show', :status => :created, :location => version_url(@version)
105 end
105 end
106 end
106 end
107 else
107 else
108 respond_to do |format|
108 respond_to do |format|
109 format.html { render :action => 'new' }
109 format.html { render :action => 'new' }
110 format.js { render :action => 'new' }
110 format.js { render :action => 'new' }
111 format.api { render_validation_errors(@version) }
111 format.api { render_validation_errors(@version) }
112 end
112 end
113 end
113 end
114 end
114 end
115 end
115 end
116
116
117 def edit
117 def edit
118 end
118 end
119
119
120 def update
120 def update
121 if params[:version]
121 if params[:version]
122 attributes = params[:version].dup
122 attributes = params[:version].dup
123 attributes.delete('sharing') unless @version.allowed_sharings.include?(attributes['sharing'])
123 attributes.delete('sharing') unless @version.allowed_sharings.include?(attributes['sharing'])
124 @version.safe_attributes = attributes
124 @version.safe_attributes = attributes
125 if @version.save
125 if @version.save
126 respond_to do |format|
126 respond_to do |format|
127 format.html {
127 format.html {
128 flash[:notice] = l(:notice_successful_update)
128 flash[:notice] = l(:notice_successful_update)
129 redirect_back_or_default settings_project_path(@project, :tab => 'versions')
129 redirect_back_or_default settings_project_path(@project, :tab => 'versions')
130 }
130 }
131 format.api { render_api_ok }
131 format.api { render_api_ok }
132 end
132 end
133 else
133 else
134 respond_to do |format|
134 respond_to do |format|
135 format.html { render :action => 'edit' }
135 format.html { render :action => 'edit' }
136 format.api { render_validation_errors(@version) }
136 format.api { render_validation_errors(@version) }
137 end
137 end
138 end
138 end
139 end
139 end
140 end
140 end
141
141
142 def close_completed
142 def close_completed
143 if request.put?
143 if request.put?
144 @project.close_completed_versions
144 @project.close_completed_versions
145 end
145 end
146 redirect_to settings_project_path(@project, :tab => 'versions')
146 redirect_to settings_project_path(@project, :tab => 'versions')
147 end
147 end
148
148
149 def destroy
149 def destroy
150 if @version.deletable?
150 if @version.deletable?
151 @version.destroy
151 @version.destroy
152 respond_to do |format|
152 respond_to do |format|
153 format.html { redirect_back_or_default settings_project_path(@project, :tab => 'versions') }
153 format.html { redirect_back_or_default settings_project_path(@project, :tab => 'versions') }
154 format.api { render_api_ok }
154 format.api { render_api_ok }
155 end
155 end
156 else
156 else
157 respond_to do |format|
157 respond_to do |format|
158 format.html {
158 format.html {
159 flash[:error] = l(:notice_unable_delete_version)
159 flash[:error] = l(:notice_unable_delete_version)
160 redirect_to settings_project_path(@project, :tab => 'versions')
160 redirect_to settings_project_path(@project, :tab => 'versions')
161 }
161 }
162 format.api { head :unprocessable_entity }
162 format.api { head :unprocessable_entity }
163 end
163 end
164 end
164 end
165 end
165 end
166
166
167 def status_by
167 def status_by
168 respond_to do |format|
168 respond_to do |format|
169 format.html { render :action => 'show' }
169 format.html { render :action => 'show' }
170 format.js
170 format.js
171 end
171 end
172 end
172 end
173
173
174 private
174 private
175
175
176 def retrieve_selected_tracker_ids(selectable_trackers, default_trackers=nil)
176 def retrieve_selected_tracker_ids(selectable_trackers, default_trackers=nil)
177 if ids = params[:tracker_ids]
177 if ids = params[:tracker_ids]
178 @selected_tracker_ids = (ids.is_a? Array) ? ids.collect { |id| id.to_i.to_s } : ids.split('/').collect { |id| id.to_i.to_s }
178 @selected_tracker_ids = (ids.is_a? Array) ? ids.collect { |id| id.to_i.to_s } : ids.split('/').collect { |id| id.to_i.to_s }
179 else
179 else
180 @selected_tracker_ids = (default_trackers || selectable_trackers).collect {|t| t.id.to_s }
180 @selected_tracker_ids = (default_trackers || selectable_trackers).collect {|t| t.id.to_s }
181 end
181 end
182 end
182 end
183 end
183 end
@@ -1,113 +1,113
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssueStatus < ActiveRecord::Base
18 class IssueStatus < ActiveRecord::Base
19 before_destroy :check_integrity
19 before_destroy :check_integrity
20 has_many :workflows, :class_name => 'WorkflowTransition', :foreign_key => "old_status_id"
20 has_many :workflows, :class_name => 'WorkflowTransition', :foreign_key => "old_status_id"
21 has_many :workflow_transitions_as_new_status, :class_name => 'WorkflowTransition', :foreign_key => "new_status_id"
21 has_many :workflow_transitions_as_new_status, :class_name => 'WorkflowTransition', :foreign_key => "new_status_id"
22 acts_as_positioned
22 acts_as_positioned
23
23
24 after_update :handle_is_closed_change
24 after_update :handle_is_closed_change
25 before_destroy :delete_workflow_rules
25 before_destroy :delete_workflow_rules
26
26
27 validates_presence_of :name
27 validates_presence_of :name
28 validates_uniqueness_of :name
28 validates_uniqueness_of :name
29 validates_length_of :name, :maximum => 30
29 validates_length_of :name, :maximum => 30
30 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
30 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
31 attr_protected :id
31 attr_protected :id
32
32
33 scope :sorted, lambda { order(:position) }
33 scope :sorted, lambda { order(:position) }
34 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
34 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
35
35
36 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
36 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
37 def self.update_issue_done_ratios
37 def self.update_issue_done_ratios
38 if Issue.use_status_for_done_ratio?
38 if Issue.use_status_for_done_ratio?
39 IssueStatus.where("default_done_ratio >= 0").each do |status|
39 IssueStatus.where("default_done_ratio >= 0").each do |status|
40 Issue.where({:status_id => status.id}).update_all({:done_ratio => status.default_done_ratio})
40 Issue.where({:status_id => status.id}).update_all({:done_ratio => status.default_done_ratio})
41 end
41 end
42 end
42 end
43
43
44 return Issue.use_status_for_done_ratio?
44 return Issue.use_status_for_done_ratio?
45 end
45 end
46
46
47 # Returns an array of all statuses the given role can switch to
47 # Returns an array of all statuses the given role can switch to
48 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
48 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
49 self.class.new_statuses_allowed(self, roles, tracker, author, assignee)
49 self.class.new_statuses_allowed(self, roles, tracker, author, assignee)
50 end
50 end
51 alias :find_new_statuses_allowed_to :new_statuses_allowed_to
51 alias :find_new_statuses_allowed_to :new_statuses_allowed_to
52
52
53 def self.new_statuses_allowed(status, roles, tracker, author=false, assignee=false)
53 def self.new_statuses_allowed(status, roles, tracker, author=false, assignee=false)
54 if roles.present? && tracker
54 if roles.present? && tracker
55 status_id = status.try(:id) || 0
55 status_id = status.try(:id) || 0
56
56
57 scope = IssueStatus.
57 scope = IssueStatus.
58 joins(:workflow_transitions_as_new_status).
58 joins(:workflow_transitions_as_new_status).
59 where(:workflows => {:old_status_id => status_id, :role_id => roles.map(&:id), :tracker_id => tracker.id})
59 where(:workflows => {:old_status_id => status_id, :role_id => roles.map(&:id), :tracker_id => tracker.id})
60
60
61 unless author && assignee
61 unless author && assignee
62 if author || assignee
62 if author || assignee
63 scope = scope.where("author = ? OR assignee = ?", author, assignee)
63 scope = scope.where("author = ? OR assignee = ?", author, assignee)
64 else
64 else
65 scope = scope.where("author = ? AND assignee = ?", false, false)
65 scope = scope.where("author = ? AND assignee = ?", false, false)
66 end
66 end
67 end
67 end
68
68
69 scope.uniq.to_a.sort
69 scope.distinct.to_a.sort
70 else
70 else
71 []
71 []
72 end
72 end
73 end
73 end
74
74
75 def <=>(status)
75 def <=>(status)
76 position <=> status.position
76 position <=> status.position
77 end
77 end
78
78
79 def to_s; name end
79 def to_s; name end
80
80
81 private
81 private
82
82
83 # Updates issues closed_on attribute when an existing status is set as closed.
83 # Updates issues closed_on attribute when an existing status is set as closed.
84 def handle_is_closed_change
84 def handle_is_closed_change
85 if is_closed_changed? && is_closed == true
85 if is_closed_changed? && is_closed == true
86 # First we update issues that have a journal for when the current status was set,
86 # First we update issues that have a journal for when the current status was set,
87 # a subselect is used to update all issues with a single query
87 # a subselect is used to update all issues with a single query
88 subselect = "SELECT MAX(j.created_on) FROM #{Journal.table_name} j" +
88 subselect = "SELECT MAX(j.created_on) FROM #{Journal.table_name} j" +
89 " JOIN #{JournalDetail.table_name} d ON d.journal_id = j.id" +
89 " JOIN #{JournalDetail.table_name} d ON d.journal_id = j.id" +
90 " WHERE j.journalized_type = 'Issue' AND j.journalized_id = #{Issue.table_name}.id" +
90 " WHERE j.journalized_type = 'Issue' AND j.journalized_id = #{Issue.table_name}.id" +
91 " AND d.property = 'attr' AND d.prop_key = 'status_id' AND d.value = :status_id"
91 " AND d.property = 'attr' AND d.prop_key = 'status_id' AND d.value = :status_id"
92 Issue.where(:status_id => id, :closed_on => nil).
92 Issue.where(:status_id => id, :closed_on => nil).
93 update_all(["closed_on = (#{subselect})", {:status_id => id.to_s}])
93 update_all(["closed_on = (#{subselect})", {:status_id => id.to_s}])
94
94
95 # Then we update issues that don't have a journal which means the
95 # Then we update issues that don't have a journal which means the
96 # current status was set on creation
96 # current status was set on creation
97 Issue.where(:status_id => id, :closed_on => nil).update_all("closed_on = created_on")
97 Issue.where(:status_id => id, :closed_on => nil).update_all("closed_on = created_on")
98 end
98 end
99 end
99 end
100
100
101 def check_integrity
101 def check_integrity
102 if Issue.where(:status_id => id).any?
102 if Issue.where(:status_id => id).any?
103 raise "This status is used by some issues"
103 raise "This status is used by some issues"
104 elsif Tracker.where(:default_status_id => id).any?
104 elsif Tracker.where(:default_status_id => id).any?
105 raise "This status is used as the default status by some trackers"
105 raise "This status is used as the default status by some trackers"
106 end
106 end
107 end
107 end
108
108
109 # Deletes associated workflows
109 # Deletes associated workflows
110 def delete_workflow_rules
110 def delete_workflow_rules
111 WorkflowRule.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
111 WorkflowRule.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
112 end
112 end
113 end
113 end
@@ -1,312 +1,312
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Journal < ActiveRecord::Base
18 class Journal < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 belongs_to :journalized, :polymorphic => true
21 belongs_to :journalized, :polymorphic => true
22 # added as a quick fix to allow eager loading of the polymorphic association
22 # added as a quick fix to allow eager loading of the polymorphic association
23 # since always associated to an issue, for now
23 # since always associated to an issue, for now
24 belongs_to :issue, :foreign_key => :journalized_id
24 belongs_to :issue, :foreign_key => :journalized_id
25
25
26 belongs_to :user
26 belongs_to :user
27 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all, :inverse_of => :journal
27 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all, :inverse_of => :journal
28 attr_accessor :indice
28 attr_accessor :indice
29 attr_protected :id
29 attr_protected :id
30
30
31 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
31 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
32 :description => :notes,
32 :description => :notes,
33 :author => :user,
33 :author => :user,
34 :group => :issue,
34 :group => :issue,
35 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
35 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
36 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
36 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
37
37
38 acts_as_activity_provider :type => 'issues',
38 acts_as_activity_provider :type => 'issues',
39 :author_key => :user_id,
39 :author_key => :user_id,
40 :scope => preload({:issue => :project}, :user).
40 :scope => preload({:issue => :project}, :user).
41 joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
41 joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
42 where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
42 where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
43 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").uniq
43 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").distinct
44
44
45 before_create :split_private_notes
45 before_create :split_private_notes
46 after_create :send_notification
46 after_create :send_notification
47
47
48 scope :visible, lambda {|*args|
48 scope :visible, lambda {|*args|
49 user = args.shift || User.current
49 user = args.shift || User.current
50 joins(:issue => :project).
50 joins(:issue => :project).
51 where(Issue.visible_condition(user, *args)).
51 where(Issue.visible_condition(user, *args)).
52 where("(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes, *args)}))", false)
52 where("(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes, *args)}))", false)
53 }
53 }
54
54
55 safe_attributes 'notes',
55 safe_attributes 'notes',
56 :if => lambda {|journal, user| journal.new_record? || journal.editable_by?(user)}
56 :if => lambda {|journal, user| journal.new_record? || journal.editable_by?(user)}
57 safe_attributes 'private_notes',
57 safe_attributes 'private_notes',
58 :if => lambda {|journal, user| user.allowed_to?(:set_notes_private, journal.project)}
58 :if => lambda {|journal, user| user.allowed_to?(:set_notes_private, journal.project)}
59
59
60 def initialize(*args)
60 def initialize(*args)
61 super
61 super
62 if journalized
62 if journalized
63 if journalized.new_record?
63 if journalized.new_record?
64 self.notify = false
64 self.notify = false
65 else
65 else
66 start
66 start
67 end
67 end
68 end
68 end
69 end
69 end
70
70
71 def save(*args)
71 def save(*args)
72 journalize_changes
72 journalize_changes
73 # Do not save an empty journal
73 # Do not save an empty journal
74 (details.empty? && notes.blank?) ? false : super
74 (details.empty? && notes.blank?) ? false : super
75 end
75 end
76
76
77 # Returns journal details that are visible to user
77 # Returns journal details that are visible to user
78 def visible_details(user=User.current)
78 def visible_details(user=User.current)
79 details.select do |detail|
79 details.select do |detail|
80 if detail.property == 'cf'
80 if detail.property == 'cf'
81 detail.custom_field && detail.custom_field.visible_by?(project, user)
81 detail.custom_field && detail.custom_field.visible_by?(project, user)
82 elsif detail.property == 'relation'
82 elsif detail.property == 'relation'
83 Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
83 Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
84 else
84 else
85 true
85 true
86 end
86 end
87 end
87 end
88 end
88 end
89
89
90 def each_notification(users, &block)
90 def each_notification(users, &block)
91 if users.any?
91 if users.any?
92 users_by_details_visibility = users.group_by do |user|
92 users_by_details_visibility = users.group_by do |user|
93 visible_details(user)
93 visible_details(user)
94 end
94 end
95 users_by_details_visibility.each do |visible_details, users|
95 users_by_details_visibility.each do |visible_details, users|
96 if notes? || visible_details.any?
96 if notes? || visible_details.any?
97 yield(users)
97 yield(users)
98 end
98 end
99 end
99 end
100 end
100 end
101 end
101 end
102
102
103 # Returns the JournalDetail for the given attribute, or nil if the attribute
103 # Returns the JournalDetail for the given attribute, or nil if the attribute
104 # was not updated
104 # was not updated
105 def detail_for_attribute(attribute)
105 def detail_for_attribute(attribute)
106 details.detect {|detail| detail.prop_key == attribute}
106 details.detect {|detail| detail.prop_key == attribute}
107 end
107 end
108
108
109 # Returns the new status if the journal contains a status change, otherwise nil
109 # Returns the new status if the journal contains a status change, otherwise nil
110 def new_status
110 def new_status
111 s = new_value_for('status_id')
111 s = new_value_for('status_id')
112 s ? IssueStatus.find_by_id(s.to_i) : nil
112 s ? IssueStatus.find_by_id(s.to_i) : nil
113 end
113 end
114
114
115 def new_value_for(prop)
115 def new_value_for(prop)
116 detail_for_attribute(prop).try(:value)
116 detail_for_attribute(prop).try(:value)
117 end
117 end
118
118
119 def editable_by?(usr)
119 def editable_by?(usr)
120 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
120 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
121 end
121 end
122
122
123 def project
123 def project
124 journalized.respond_to?(:project) ? journalized.project : nil
124 journalized.respond_to?(:project) ? journalized.project : nil
125 end
125 end
126
126
127 def attachments
127 def attachments
128 journalized.respond_to?(:attachments) ? journalized.attachments : nil
128 journalized.respond_to?(:attachments) ? journalized.attachments : nil
129 end
129 end
130
130
131 # Returns a string of css classes
131 # Returns a string of css classes
132 def css_classes
132 def css_classes
133 s = 'journal'
133 s = 'journal'
134 s << ' has-notes' unless notes.blank?
134 s << ' has-notes' unless notes.blank?
135 s << ' has-details' unless details.blank?
135 s << ' has-details' unless details.blank?
136 s << ' private-notes' if private_notes?
136 s << ' private-notes' if private_notes?
137 s
137 s
138 end
138 end
139
139
140 def notify?
140 def notify?
141 @notify != false
141 @notify != false
142 end
142 end
143
143
144 def notify=(arg)
144 def notify=(arg)
145 @notify = arg
145 @notify = arg
146 end
146 end
147
147
148 def notified_users
148 def notified_users
149 notified = journalized.notified_users
149 notified = journalized.notified_users
150 if private_notes?
150 if private_notes?
151 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
151 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
152 end
152 end
153 notified
153 notified
154 end
154 end
155
155
156 def recipients
156 def recipients
157 notified_users.map(&:mail)
157 notified_users.map(&:mail)
158 end
158 end
159
159
160 def notified_watchers
160 def notified_watchers
161 notified = journalized.notified_watchers
161 notified = journalized.notified_watchers
162 if private_notes?
162 if private_notes?
163 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
163 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
164 end
164 end
165 notified
165 notified
166 end
166 end
167
167
168 def watcher_recipients
168 def watcher_recipients
169 notified_watchers.map(&:mail)
169 notified_watchers.map(&:mail)
170 end
170 end
171
171
172 # Sets @custom_field instance variable on journals details using a single query
172 # Sets @custom_field instance variable on journals details using a single query
173 def self.preload_journals_details_custom_fields(journals)
173 def self.preload_journals_details_custom_fields(journals)
174 field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
174 field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
175 if field_ids.any?
175 if field_ids.any?
176 fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
176 fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
177 journals.each do |journal|
177 journals.each do |journal|
178 journal.details.each do |detail|
178 journal.details.each do |detail|
179 if detail.property == 'cf'
179 if detail.property == 'cf'
180 detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
180 detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
181 end
181 end
182 end
182 end
183 end
183 end
184 end
184 end
185 journals
185 journals
186 end
186 end
187
187
188 # Stores the values of the attributes and custom fields of the journalized object
188 # Stores the values of the attributes and custom fields of the journalized object
189 def start
189 def start
190 if journalized
190 if journalized
191 @attributes_before_change = journalized.journalized_attribute_names.inject({}) do |h, attribute|
191 @attributes_before_change = journalized.journalized_attribute_names.inject({}) do |h, attribute|
192 h[attribute] = journalized.send(attribute)
192 h[attribute] = journalized.send(attribute)
193 h
193 h
194 end
194 end
195 @custom_values_before_change = journalized.custom_field_values.inject({}) do |h, c|
195 @custom_values_before_change = journalized.custom_field_values.inject({}) do |h, c|
196 h[c.custom_field_id] = c.value
196 h[c.custom_field_id] = c.value
197 h
197 h
198 end
198 end
199 end
199 end
200 self
200 self
201 end
201 end
202
202
203 # Adds a journal detail for an attachment that was added or removed
203 # Adds a journal detail for an attachment that was added or removed
204 def journalize_attachment(attachment, added_or_removed)
204 def journalize_attachment(attachment, added_or_removed)
205 key = (added_or_removed == :removed ? :old_value : :value)
205 key = (added_or_removed == :removed ? :old_value : :value)
206 details << JournalDetail.new(
206 details << JournalDetail.new(
207 :property => 'attachment',
207 :property => 'attachment',
208 :prop_key => attachment.id,
208 :prop_key => attachment.id,
209 key => attachment.filename
209 key => attachment.filename
210 )
210 )
211 end
211 end
212
212
213 # Adds a journal detail for an issue relation that was added or removed
213 # Adds a journal detail for an issue relation that was added or removed
214 def journalize_relation(relation, added_or_removed)
214 def journalize_relation(relation, added_or_removed)
215 key = (added_or_removed == :removed ? :old_value : :value)
215 key = (added_or_removed == :removed ? :old_value : :value)
216 details << JournalDetail.new(
216 details << JournalDetail.new(
217 :property => 'relation',
217 :property => 'relation',
218 :prop_key => relation.relation_type_for(journalized),
218 :prop_key => relation.relation_type_for(journalized),
219 key => relation.other_issue(journalized).try(:id)
219 key => relation.other_issue(journalized).try(:id)
220 )
220 )
221 end
221 end
222
222
223 private
223 private
224
224
225 # Generates journal details for attribute and custom field changes
225 # Generates journal details for attribute and custom field changes
226 def journalize_changes
226 def journalize_changes
227 # attributes changes
227 # attributes changes
228 if @attributes_before_change
228 if @attributes_before_change
229 journalized.journalized_attribute_names.each {|attribute|
229 journalized.journalized_attribute_names.each {|attribute|
230 before = @attributes_before_change[attribute]
230 before = @attributes_before_change[attribute]
231 after = journalized.send(attribute)
231 after = journalized.send(attribute)
232 next if before == after || (before.blank? && after.blank?)
232 next if before == after || (before.blank? && after.blank?)
233 add_attribute_detail(attribute, before, after)
233 add_attribute_detail(attribute, before, after)
234 }
234 }
235 end
235 end
236 if @custom_values_before_change
236 if @custom_values_before_change
237 # custom fields changes
237 # custom fields changes
238 journalized.custom_field_values.each {|c|
238 journalized.custom_field_values.each {|c|
239 before = @custom_values_before_change[c.custom_field_id]
239 before = @custom_values_before_change[c.custom_field_id]
240 after = c.value
240 after = c.value
241 next if before == after || (before.blank? && after.blank?)
241 next if before == after || (before.blank? && after.blank?)
242
242
243 if before.is_a?(Array) || after.is_a?(Array)
243 if before.is_a?(Array) || after.is_a?(Array)
244 before = [before] unless before.is_a?(Array)
244 before = [before] unless before.is_a?(Array)
245 after = [after] unless after.is_a?(Array)
245 after = [after] unless after.is_a?(Array)
246
246
247 # values removed
247 # values removed
248 (before - after).reject(&:blank?).each do |value|
248 (before - after).reject(&:blank?).each do |value|
249 add_custom_value_detail(c, value, nil)
249 add_custom_value_detail(c, value, nil)
250 end
250 end
251 # values added
251 # values added
252 (after - before).reject(&:blank?).each do |value|
252 (after - before).reject(&:blank?).each do |value|
253 add_custom_value_detail(c, nil, value)
253 add_custom_value_detail(c, nil, value)
254 end
254 end
255 else
255 else
256 add_custom_value_detail(c, before, after)
256 add_custom_value_detail(c, before, after)
257 end
257 end
258 }
258 }
259 end
259 end
260 start
260 start
261 end
261 end
262
262
263 # Adds a journal detail for an attribute change
263 # Adds a journal detail for an attribute change
264 def add_attribute_detail(attribute, old_value, value)
264 def add_attribute_detail(attribute, old_value, value)
265 add_detail('attr', attribute, old_value, value)
265 add_detail('attr', attribute, old_value, value)
266 end
266 end
267
267
268 # Adds a journal detail for a custom field value change
268 # Adds a journal detail for a custom field value change
269 def add_custom_value_detail(custom_value, old_value, value)
269 def add_custom_value_detail(custom_value, old_value, value)
270 add_detail('cf', custom_value.custom_field_id, old_value, value)
270 add_detail('cf', custom_value.custom_field_id, old_value, value)
271 end
271 end
272
272
273 # Adds a journal detail
273 # Adds a journal detail
274 def add_detail(property, prop_key, old_value, value)
274 def add_detail(property, prop_key, old_value, value)
275 details << JournalDetail.new(
275 details << JournalDetail.new(
276 :property => property,
276 :property => property,
277 :prop_key => prop_key,
277 :prop_key => prop_key,
278 :old_value => old_value,
278 :old_value => old_value,
279 :value => value
279 :value => value
280 )
280 )
281 end
281 end
282
282
283 def split_private_notes
283 def split_private_notes
284 if private_notes?
284 if private_notes?
285 if notes.present?
285 if notes.present?
286 if details.any?
286 if details.any?
287 # Split the journal (notes/changes) so we don't have half-private journals
287 # Split the journal (notes/changes) so we don't have half-private journals
288 journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
288 journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
289 journal.details = details
289 journal.details = details
290 journal.save
290 journal.save
291 self.details = []
291 self.details = []
292 self.created_on = journal.created_on
292 self.created_on = journal.created_on
293 end
293 end
294 else
294 else
295 # Blank notes should not be private
295 # Blank notes should not be private
296 self.private_notes = false
296 self.private_notes = false
297 end
297 end
298 end
298 end
299 true
299 true
300 end
300 end
301
301
302 def send_notification
302 def send_notification
303 if notify? && (Setting.notified_events.include?('issue_updated') ||
303 if notify? && (Setting.notified_events.include?('issue_updated') ||
304 (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
304 (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
305 (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
305 (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
306 (Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
306 (Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
307 (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
307 (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
308 )
308 )
309 Mailer.deliver_issue_edit(self)
309 Mailer.deliver_issue_edit(self)
310 end
310 end
311 end
311 end
312 end
312 end
@@ -1,198 +1,198
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Member < ActiveRecord::Base
18 class Member < ActiveRecord::Base
19 belongs_to :user
19 belongs_to :user
20 belongs_to :principal, :foreign_key => 'user_id'
20 belongs_to :principal, :foreign_key => 'user_id'
21 has_many :member_roles, :dependent => :destroy
21 has_many :member_roles, :dependent => :destroy
22 has_many :roles, lambda {uniq}, :through => :member_roles
22 has_many :roles, lambda { distinct }, :through => :member_roles
23 belongs_to :project
23 belongs_to :project
24
24
25 validates_presence_of :principal, :project
25 validates_presence_of :principal, :project
26 validates_uniqueness_of :user_id, :scope => :project_id
26 validates_uniqueness_of :user_id, :scope => :project_id
27 validate :validate_role
27 validate :validate_role
28 attr_protected :id
28 attr_protected :id
29
29
30 before_destroy :set_issue_category_nil
30 before_destroy :set_issue_category_nil
31
31
32 scope :active, lambda { joins(:principal).where(:users => {:status => Principal::STATUS_ACTIVE})}
32 scope :active, lambda { joins(:principal).where(:users => {:status => Principal::STATUS_ACTIVE})}
33
33
34 alias :base_reload :reload
34 alias :base_reload :reload
35 def reload(*args)
35 def reload(*args)
36 @managed_roles = nil
36 @managed_roles = nil
37 base_reload(*args)
37 base_reload(*args)
38 end
38 end
39
39
40 def role
40 def role
41 end
41 end
42
42
43 def role=
43 def role=
44 end
44 end
45
45
46 def name
46 def name
47 self.user.name
47 self.user.name
48 end
48 end
49
49
50 alias :base_role_ids= :role_ids=
50 alias :base_role_ids= :role_ids=
51 def role_ids=(arg)
51 def role_ids=(arg)
52 ids = (arg || []).collect(&:to_i) - [0]
52 ids = (arg || []).collect(&:to_i) - [0]
53 # Keep inherited roles
53 # Keep inherited roles
54 ids += member_roles.select {|mr| !mr.inherited_from.nil?}.collect(&:role_id)
54 ids += member_roles.select {|mr| !mr.inherited_from.nil?}.collect(&:role_id)
55
55
56 new_role_ids = ids - role_ids
56 new_role_ids = ids - role_ids
57 # Add new roles
57 # Add new roles
58 new_role_ids.each {|id| member_roles << MemberRole.new(:role_id => id, :member => self) }
58 new_role_ids.each {|id| member_roles << MemberRole.new(:role_id => id, :member => self) }
59 # Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy)
59 # Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy)
60 member_roles_to_destroy = member_roles.select {|mr| !ids.include?(mr.role_id)}
60 member_roles_to_destroy = member_roles.select {|mr| !ids.include?(mr.role_id)}
61 if member_roles_to_destroy.any?
61 if member_roles_to_destroy.any?
62 member_roles_to_destroy.each(&:destroy)
62 member_roles_to_destroy.each(&:destroy)
63 end
63 end
64 end
64 end
65
65
66 def <=>(member)
66 def <=>(member)
67 a, b = roles.sort, member.roles.sort
67 a, b = roles.sort, member.roles.sort
68 if a == b
68 if a == b
69 if principal
69 if principal
70 principal <=> member.principal
70 principal <=> member.principal
71 else
71 else
72 1
72 1
73 end
73 end
74 elsif a.any?
74 elsif a.any?
75 b.any? ? a <=> b : -1
75 b.any? ? a <=> b : -1
76 else
76 else
77 1
77 1
78 end
78 end
79 end
79 end
80
80
81 # Set member role ids ignoring any change to roles that
81 # Set member role ids ignoring any change to roles that
82 # user is not allowed to manage
82 # user is not allowed to manage
83 def set_editable_role_ids(ids, user=User.current)
83 def set_editable_role_ids(ids, user=User.current)
84 ids = (ids || []).collect(&:to_i) - [0]
84 ids = (ids || []).collect(&:to_i) - [0]
85 editable_role_ids = user.managed_roles(project).map(&:id)
85 editable_role_ids = user.managed_roles(project).map(&:id)
86 untouched_role_ids = self.role_ids - editable_role_ids
86 untouched_role_ids = self.role_ids - editable_role_ids
87 touched_role_ids = ids & editable_role_ids
87 touched_role_ids = ids & editable_role_ids
88 self.role_ids = untouched_role_ids + touched_role_ids
88 self.role_ids = untouched_role_ids + touched_role_ids
89 end
89 end
90
90
91 # Returns true if one of the member roles is inherited
91 # Returns true if one of the member roles is inherited
92 def any_inherited_role?
92 def any_inherited_role?
93 member_roles.any? {|mr| mr.inherited_from}
93 member_roles.any? {|mr| mr.inherited_from}
94 end
94 end
95
95
96 # Returns true if the member has the role and if it's inherited
96 # Returns true if the member has the role and if it's inherited
97 def has_inherited_role?(role)
97 def has_inherited_role?(role)
98 member_roles.any? {|mr| mr.role_id == role.id && mr.inherited_from.present?}
98 member_roles.any? {|mr| mr.role_id == role.id && mr.inherited_from.present?}
99 end
99 end
100
100
101 # Returns true if the member's role is editable by user
101 # Returns true if the member's role is editable by user
102 def role_editable?(role, user=User.current)
102 def role_editable?(role, user=User.current)
103 if has_inherited_role?(role)
103 if has_inherited_role?(role)
104 false
104 false
105 else
105 else
106 user.managed_roles(project).include?(role)
106 user.managed_roles(project).include?(role)
107 end
107 end
108 end
108 end
109
109
110 # Returns true if the member is deletable by user
110 # Returns true if the member is deletable by user
111 def deletable?(user=User.current)
111 def deletable?(user=User.current)
112 if any_inherited_role?
112 if any_inherited_role?
113 false
113 false
114 else
114 else
115 roles & user.managed_roles(project) == roles
115 roles & user.managed_roles(project) == roles
116 end
116 end
117 end
117 end
118
118
119 # Destroys the member
119 # Destroys the member
120 def destroy
120 def destroy
121 member_roles.reload.each(&:destroy_without_member_removal)
121 member_roles.reload.each(&:destroy_without_member_removal)
122 super
122 super
123 end
123 end
124
124
125 # Returns true if the member is user or is a group
125 # Returns true if the member is user or is a group
126 # that includes user
126 # that includes user
127 def include?(user)
127 def include?(user)
128 if principal.is_a?(Group)
128 if principal.is_a?(Group)
129 !user.nil? && user.groups.include?(principal)
129 !user.nil? && user.groups.include?(principal)
130 else
130 else
131 self.user == user
131 self.user == user
132 end
132 end
133 end
133 end
134
134
135 def set_issue_category_nil
135 def set_issue_category_nil
136 if user_id && project_id
136 if user_id && project_id
137 # remove category based auto assignments for this member
137 # remove category based auto assignments for this member
138 IssueCategory.where(["project_id = ? AND assigned_to_id = ?", project_id, user_id]).
138 IssueCategory.where(["project_id = ? AND assigned_to_id = ?", project_id, user_id]).
139 update_all("assigned_to_id = NULL")
139 update_all("assigned_to_id = NULL")
140 end
140 end
141 end
141 end
142
142
143 # Returns the roles that the member is allowed to manage
143 # Returns the roles that the member is allowed to manage
144 # in the project the member belongs to
144 # in the project the member belongs to
145 def managed_roles
145 def managed_roles
146 @managed_roles ||= begin
146 @managed_roles ||= begin
147 if principal.try(:admin?)
147 if principal.try(:admin?)
148 Role.givable.to_a
148 Role.givable.to_a
149 else
149 else
150 members_management_roles = roles.select do |role|
150 members_management_roles = roles.select do |role|
151 role.has_permission?(:manage_members)
151 role.has_permission?(:manage_members)
152 end
152 end
153 if members_management_roles.empty?
153 if members_management_roles.empty?
154 []
154 []
155 elsif members_management_roles.any?(&:all_roles_managed?)
155 elsif members_management_roles.any?(&:all_roles_managed?)
156 Role.givable.to_a
156 Role.givable.to_a
157 else
157 else
158 members_management_roles.map(&:managed_roles).reduce(&:|)
158 members_management_roles.map(&:managed_roles).reduce(&:|)
159 end
159 end
160 end
160 end
161 end
161 end
162 end
162 end
163
163
164 # Creates memberships for principal with the attributes
164 # Creates memberships for principal with the attributes
165 # * project_ids : one or more project ids
165 # * project_ids : one or more project ids
166 # * role_ids : ids of the roles to give to each membership
166 # * role_ids : ids of the roles to give to each membership
167 #
167 #
168 # Example:
168 # Example:
169 # Member.create_principal_memberships(user, :project_ids => [2, 5], :role_ids => [1, 3]
169 # Member.create_principal_memberships(user, :project_ids => [2, 5], :role_ids => [1, 3]
170 def self.create_principal_memberships(principal, attributes)
170 def self.create_principal_memberships(principal, attributes)
171 members = []
171 members = []
172 if attributes
172 if attributes
173 project_ids = Array.wrap(attributes[:project_ids] || attributes[:project_id])
173 project_ids = Array.wrap(attributes[:project_ids] || attributes[:project_id])
174 role_ids = attributes[:role_ids]
174 role_ids = attributes[:role_ids]
175 project_ids.each do |project_id|
175 project_ids.each do |project_id|
176 members << Member.new(:principal => principal, :role_ids => role_ids, :project_id => project_id)
176 members << Member.new(:principal => principal, :role_ids => role_ids, :project_id => project_id)
177 end
177 end
178 principal.members << members
178 principal.members << members
179 end
179 end
180 members
180 members
181 end
181 end
182
182
183 # Finds or initilizes a Member for the given project and principal
183 # Finds or initilizes a Member for the given project and principal
184 def self.find_or_new(project, principal)
184 def self.find_or_new(project, principal)
185 project_id = project.is_a?(Project) ? project.id : project
185 project_id = project.is_a?(Project) ? project.id : project
186 principal_id = principal.is_a?(Principal) ? principal.id : principal
186 principal_id = principal.is_a?(Principal) ? principal.id : principal
187
187
188 member = Member.find_by_project_id_and_user_id(project_id, principal_id)
188 member = Member.find_by_project_id_and_user_id(project_id, principal_id)
189 member ||= Member.new(:project_id => project_id, :user_id => principal_id)
189 member ||= Member.new(:project_id => project_id, :user_id => principal_id)
190 member
190 member
191 end
191 end
192
192
193 protected
193 protected
194
194
195 def validate_role
195 def validate_role
196 errors.add_on_empty :role if member_roles.empty? && roles.empty?
196 errors.add_on_empty :role if member_roles.empty? && roles.empty?
197 end
197 end
198 end
198 end
@@ -1,1066 +1,1066
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Project < ActiveRecord::Base
18 class Project < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::NestedSet::ProjectNestedSet
20 include Redmine::NestedSet::ProjectNestedSet
21
21
22 # Project statuses
22 # Project statuses
23 STATUS_ACTIVE = 1
23 STATUS_ACTIVE = 1
24 STATUS_CLOSED = 5
24 STATUS_CLOSED = 5
25 STATUS_ARCHIVED = 9
25 STATUS_ARCHIVED = 9
26
26
27 # Maximum length for project identifiers
27 # Maximum length for project identifiers
28 IDENTIFIER_MAX_LENGTH = 100
28 IDENTIFIER_MAX_LENGTH = 100
29
29
30 # Specific overridden Activities
30 # Specific overridden Activities
31 has_many :time_entry_activities
31 has_many :time_entry_activities
32 has_many :memberships, :class_name => 'Member', :inverse_of => :project
32 has_many :memberships, :class_name => 'Member', :inverse_of => :project
33 # Memberships of active users only
33 # Memberships of active users only
34 has_many :members,
34 has_many :members,
35 lambda { joins(:principal).where(:users => {:type => 'User', :status => Principal::STATUS_ACTIVE}) }
35 lambda { joins(:principal).where(:users => {:type => 'User', :status => Principal::STATUS_ACTIVE}) }
36 has_many :enabled_modules, :dependent => :delete_all
36 has_many :enabled_modules, :dependent => :delete_all
37 has_and_belongs_to_many :trackers, lambda {order(:position)}
37 has_and_belongs_to_many :trackers, lambda {order(:position)}
38 has_many :issues, :dependent => :destroy
38 has_many :issues, :dependent => :destroy
39 has_many :issue_changes, :through => :issues, :source => :journals
39 has_many :issue_changes, :through => :issues, :source => :journals
40 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
40 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
41 belongs_to :default_version, :class_name => 'Version'
41 belongs_to :default_version, :class_name => 'Version'
42 has_many :time_entries, :dependent => :destroy
42 has_many :time_entries, :dependent => :destroy
43 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
43 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
44 has_many :documents, :dependent => :destroy
44 has_many :documents, :dependent => :destroy
45 has_many :news, lambda {includes(:author)}, :dependent => :destroy
45 has_many :news, lambda {includes(:author)}, :dependent => :destroy
46 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
46 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
47 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
47 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
48 has_one :repository, lambda {where(["is_default = ?", true])}
48 has_one :repository, lambda {where(["is_default = ?", true])}
49 has_many :repositories, :dependent => :destroy
49 has_many :repositories, :dependent => :destroy
50 has_many :changesets, :through => :repository
50 has_many :changesets, :through => :repository
51 has_one :wiki, :dependent => :destroy
51 has_one :wiki, :dependent => :destroy
52 # Custom field for the project issues
52 # Custom field for the project issues
53 has_and_belongs_to_many :issue_custom_fields,
53 has_and_belongs_to_many :issue_custom_fields,
54 lambda {order("#{CustomField.table_name}.position")},
54 lambda {order("#{CustomField.table_name}.position")},
55 :class_name => 'IssueCustomField',
55 :class_name => 'IssueCustomField',
56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 :association_foreign_key => 'custom_field_id'
57 :association_foreign_key => 'custom_field_id'
58
58
59 acts_as_attachable :view_permission => :view_files,
59 acts_as_attachable :view_permission => :view_files,
60 :edit_permission => :manage_files,
60 :edit_permission => :manage_files,
61 :delete_permission => :manage_files
61 :delete_permission => :manage_files
62
62
63 acts_as_customizable
63 acts_as_customizable
64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => "#{Project.table_name}.id", :permission => nil
64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => "#{Project.table_name}.id", :permission => nil
65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 :author => nil
67 :author => nil
68
68
69 attr_protected :status
69 attr_protected :status
70
70
71 validates_presence_of :name, :identifier
71 validates_presence_of :name, :identifier
72 validates_uniqueness_of :identifier, :if => Proc.new {|p| p.identifier_changed?}
72 validates_uniqueness_of :identifier, :if => Proc.new {|p| p.identifier_changed?}
73 validates_length_of :name, :maximum => 255
73 validates_length_of :name, :maximum => 255
74 validates_length_of :homepage, :maximum => 255
74 validates_length_of :homepage, :maximum => 255
75 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
75 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
76 # downcase letters, digits, dashes but not digits only
76 # downcase letters, digits, dashes but not digits only
77 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
77 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
78 # reserved words
78 # reserved words
79 validates_exclusion_of :identifier, :in => %w( new )
79 validates_exclusion_of :identifier, :in => %w( new )
80 validate :validate_parent
80 validate :validate_parent
81
81
82 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
82 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
83 after_save :remove_inherited_member_roles, :add_inherited_member_roles, :if => Proc.new {|project| project.parent_id_changed?}
83 after_save :remove_inherited_member_roles, :add_inherited_member_roles, :if => Proc.new {|project| project.parent_id_changed?}
84 after_update :update_versions_from_hierarchy_change, :if => Proc.new {|project| project.parent_id_changed?}
84 after_update :update_versions_from_hierarchy_change, :if => Proc.new {|project| project.parent_id_changed?}
85 before_destroy :delete_all_members
85 before_destroy :delete_all_members
86
86
87 scope :has_module, lambda {|mod|
87 scope :has_module, lambda {|mod|
88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 }
89 }
90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 scope :all_public, lambda { where(:is_public => true) }
92 scope :all_public, lambda { where(:is_public => true) }
93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 scope :allowed_to, lambda {|*args|
94 scope :allowed_to, lambda {|*args|
95 user = User.current
95 user = User.current
96 permission = nil
96 permission = nil
97 if args.first.is_a?(Symbol)
97 if args.first.is_a?(Symbol)
98 permission = args.shift
98 permission = args.shift
99 else
99 else
100 user = args.shift
100 user = args.shift
101 permission = args.shift
101 permission = args.shift
102 end
102 end
103 where(Project.allowed_to_condition(user, permission, *args))
103 where(Project.allowed_to_condition(user, permission, *args))
104 }
104 }
105 scope :like, lambda {|arg|
105 scope :like, lambda {|arg|
106 if arg.blank?
106 if arg.blank?
107 where(nil)
107 where(nil)
108 else
108 else
109 pattern = "%#{arg.to_s.strip.downcase}%"
109 pattern = "%#{arg.to_s.strip.downcase}%"
110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 end
111 end
112 }
112 }
113 scope :sorted, lambda {order(:lft)}
113 scope :sorted, lambda {order(:lft)}
114 scope :having_trackers, lambda {
114 scope :having_trackers, lambda {
115 where("#{Project.table_name}.id IN (SELECT DISTINCT project_id FROM #{table_name_prefix}projects_trackers#{table_name_suffix})")
115 where("#{Project.table_name}.id IN (SELECT DISTINCT project_id FROM #{table_name_prefix}projects_trackers#{table_name_suffix})")
116 }
116 }
117
117
118 def initialize(attributes=nil, *args)
118 def initialize(attributes=nil, *args)
119 super
119 super
120
120
121 initialized = (attributes || {}).stringify_keys
121 initialized = (attributes || {}).stringify_keys
122 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
122 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
123 self.identifier = Project.next_identifier
123 self.identifier = Project.next_identifier
124 end
124 end
125 if !initialized.key?('is_public')
125 if !initialized.key?('is_public')
126 self.is_public = Setting.default_projects_public?
126 self.is_public = Setting.default_projects_public?
127 end
127 end
128 if !initialized.key?('enabled_module_names')
128 if !initialized.key?('enabled_module_names')
129 self.enabled_module_names = Setting.default_projects_modules
129 self.enabled_module_names = Setting.default_projects_modules
130 end
130 end
131 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
131 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
132 default = Setting.default_projects_tracker_ids
132 default = Setting.default_projects_tracker_ids
133 if default.is_a?(Array)
133 if default.is_a?(Array)
134 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
134 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
135 else
135 else
136 self.trackers = Tracker.sorted.to_a
136 self.trackers = Tracker.sorted.to_a
137 end
137 end
138 end
138 end
139 end
139 end
140
140
141 def identifier=(identifier)
141 def identifier=(identifier)
142 super unless identifier_frozen?
142 super unless identifier_frozen?
143 end
143 end
144
144
145 def identifier_frozen?
145 def identifier_frozen?
146 errors[:identifier].blank? && !(new_record? || identifier.blank?)
146 errors[:identifier].blank? && !(new_record? || identifier.blank?)
147 end
147 end
148
148
149 # returns latest created projects
149 # returns latest created projects
150 # non public projects will be returned only if user is a member of those
150 # non public projects will be returned only if user is a member of those
151 def self.latest(user=nil, count=5)
151 def self.latest(user=nil, count=5)
152 visible(user).limit(count).
152 visible(user).limit(count).
153 order(:created_on => :desc).
153 order(:created_on => :desc).
154 where("#{table_name}.created_on >= ?", 30.days.ago).
154 where("#{table_name}.created_on >= ?", 30.days.ago).
155 to_a
155 to_a
156 end
156 end
157
157
158 # Returns true if the project is visible to +user+ or to the current user.
158 # Returns true if the project is visible to +user+ or to the current user.
159 def visible?(user=User.current)
159 def visible?(user=User.current)
160 user.allowed_to?(:view_project, self)
160 user.allowed_to?(:view_project, self)
161 end
161 end
162
162
163 # Returns a SQL conditions string used to find all projects visible by the specified user.
163 # Returns a SQL conditions string used to find all projects visible by the specified user.
164 #
164 #
165 # Examples:
165 # Examples:
166 # Project.visible_condition(admin) => "projects.status = 1"
166 # Project.visible_condition(admin) => "projects.status = 1"
167 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
167 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
168 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
168 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
169 def self.visible_condition(user, options={})
169 def self.visible_condition(user, options={})
170 allowed_to_condition(user, :view_project, options)
170 allowed_to_condition(user, :view_project, options)
171 end
171 end
172
172
173 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
173 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
174 #
174 #
175 # Valid options:
175 # Valid options:
176 # * :project => limit the condition to project
176 # * :project => limit the condition to project
177 # * :with_subprojects => limit the condition to project and its subprojects
177 # * :with_subprojects => limit the condition to project and its subprojects
178 # * :member => limit the condition to the user projects
178 # * :member => limit the condition to the user projects
179 def self.allowed_to_condition(user, permission, options={})
179 def self.allowed_to_condition(user, permission, options={})
180 perm = Redmine::AccessControl.permission(permission)
180 perm = Redmine::AccessControl.permission(permission)
181 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
181 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
182 if perm && perm.project_module
182 if perm && perm.project_module
183 # If the permission belongs to a project module, make sure the module is enabled
183 # If the permission belongs to a project module, make sure the module is enabled
184 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
184 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
185 end
185 end
186 if project = options[:project]
186 if project = options[:project]
187 project_statement = project.project_condition(options[:with_subprojects])
187 project_statement = project.project_condition(options[:with_subprojects])
188 base_statement = "(#{project_statement}) AND (#{base_statement})"
188 base_statement = "(#{project_statement}) AND (#{base_statement})"
189 end
189 end
190
190
191 if user.admin?
191 if user.admin?
192 base_statement
192 base_statement
193 else
193 else
194 statement_by_role = {}
194 statement_by_role = {}
195 unless options[:member]
195 unless options[:member]
196 role = user.builtin_role
196 role = user.builtin_role
197 if role.allowed_to?(permission)
197 if role.allowed_to?(permission)
198 s = "#{Project.table_name}.is_public = #{connection.quoted_true}"
198 s = "#{Project.table_name}.is_public = #{connection.quoted_true}"
199 if user.id
199 if user.id
200 s = "(#{s} AND #{Project.table_name}.id NOT IN (SELECT project_id FROM #{Member.table_name} WHERE user_id = #{user.id}))"
200 s = "(#{s} AND #{Project.table_name}.id NOT IN (SELECT project_id FROM #{Member.table_name} WHERE user_id = #{user.id}))"
201 end
201 end
202 statement_by_role[role] = s
202 statement_by_role[role] = s
203 end
203 end
204 end
204 end
205 user.projects_by_role.each do |role, projects|
205 user.projects_by_role.each do |role, projects|
206 if role.allowed_to?(permission) && projects.any?
206 if role.allowed_to?(permission) && projects.any?
207 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
207 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
208 end
208 end
209 end
209 end
210 if statement_by_role.empty?
210 if statement_by_role.empty?
211 "1=0"
211 "1=0"
212 else
212 else
213 if block_given?
213 if block_given?
214 statement_by_role.each do |role, statement|
214 statement_by_role.each do |role, statement|
215 if s = yield(role, user)
215 if s = yield(role, user)
216 statement_by_role[role] = "(#{statement} AND (#{s}))"
216 statement_by_role[role] = "(#{statement} AND (#{s}))"
217 end
217 end
218 end
218 end
219 end
219 end
220 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
220 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
221 end
221 end
222 end
222 end
223 end
223 end
224
224
225 def override_roles(role)
225 def override_roles(role)
226 @override_members ||= memberships.
226 @override_members ||= memberships.
227 joins(:principal).
227 joins(:principal).
228 where(:users => {:type => ['GroupAnonymous', 'GroupNonMember']}).to_a
228 where(:users => {:type => ['GroupAnonymous', 'GroupNonMember']}).to_a
229
229
230 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
230 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
231 member = @override_members.detect {|m| m.principal.is_a? group_class}
231 member = @override_members.detect {|m| m.principal.is_a? group_class}
232 member ? member.roles.to_a : [role]
232 member ? member.roles.to_a : [role]
233 end
233 end
234
234
235 def principals
235 def principals
236 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
236 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).distinct
237 end
237 end
238
238
239 def users
239 def users
240 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).uniq
240 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).distinct
241 end
241 end
242
242
243 # Returns the Systemwide and project specific activities
243 # Returns the Systemwide and project specific activities
244 def activities(include_inactive=false)
244 def activities(include_inactive=false)
245 t = TimeEntryActivity.table_name
245 t = TimeEntryActivity.table_name
246 scope = TimeEntryActivity.where("#{t}.project_id IS NULL OR #{t}.project_id = ?", id)
246 scope = TimeEntryActivity.where("#{t}.project_id IS NULL OR #{t}.project_id = ?", id)
247
247
248 overridden_activity_ids = self.time_entry_activities.pluck(:parent_id).compact
248 overridden_activity_ids = self.time_entry_activities.pluck(:parent_id).compact
249 if overridden_activity_ids.any?
249 if overridden_activity_ids.any?
250 scope = scope.where("#{t}.id NOT IN (?)", overridden_activity_ids)
250 scope = scope.where("#{t}.id NOT IN (?)", overridden_activity_ids)
251 end
251 end
252 unless include_inactive
252 unless include_inactive
253 scope = scope.active
253 scope = scope.active
254 end
254 end
255 scope
255 scope
256 end
256 end
257
257
258 # Will create a new Project specific Activity or update an existing one
258 # Will create a new Project specific Activity or update an existing one
259 #
259 #
260 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
260 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
261 # does not successfully save.
261 # does not successfully save.
262 def update_or_create_time_entry_activity(id, activity_hash)
262 def update_or_create_time_entry_activity(id, activity_hash)
263 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
263 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
264 self.create_time_entry_activity_if_needed(activity_hash)
264 self.create_time_entry_activity_if_needed(activity_hash)
265 else
265 else
266 activity = project.time_entry_activities.find_by_id(id.to_i)
266 activity = project.time_entry_activities.find_by_id(id.to_i)
267 activity.update_attributes(activity_hash) if activity
267 activity.update_attributes(activity_hash) if activity
268 end
268 end
269 end
269 end
270
270
271 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
271 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
272 #
272 #
273 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
273 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
274 # does not successfully save.
274 # does not successfully save.
275 def create_time_entry_activity_if_needed(activity)
275 def create_time_entry_activity_if_needed(activity)
276 if activity['parent_id']
276 if activity['parent_id']
277 parent_activity = TimeEntryActivity.find(activity['parent_id'])
277 parent_activity = TimeEntryActivity.find(activity['parent_id'])
278 activity['name'] = parent_activity.name
278 activity['name'] = parent_activity.name
279 activity['position'] = parent_activity.position
279 activity['position'] = parent_activity.position
280 if Enumeration.overriding_change?(activity, parent_activity)
280 if Enumeration.overriding_change?(activity, parent_activity)
281 project_activity = self.time_entry_activities.create(activity)
281 project_activity = self.time_entry_activities.create(activity)
282 if project_activity.new_record?
282 if project_activity.new_record?
283 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
283 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
284 else
284 else
285 self.time_entries.
285 self.time_entries.
286 where(:activity_id => parent_activity.id).
286 where(:activity_id => parent_activity.id).
287 update_all(:activity_id => project_activity.id)
287 update_all(:activity_id => project_activity.id)
288 end
288 end
289 end
289 end
290 end
290 end
291 end
291 end
292
292
293 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
293 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
294 #
294 #
295 # Examples:
295 # Examples:
296 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
296 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
297 # project.project_condition(false) => "projects.id = 1"
297 # project.project_condition(false) => "projects.id = 1"
298 def project_condition(with_subprojects)
298 def project_condition(with_subprojects)
299 cond = "#{Project.table_name}.id = #{id}"
299 cond = "#{Project.table_name}.id = #{id}"
300 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
300 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
301 cond
301 cond
302 end
302 end
303
303
304 def self.find(*args)
304 def self.find(*args)
305 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
305 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
306 project = find_by_identifier(*args)
306 project = find_by_identifier(*args)
307 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
307 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
308 project
308 project
309 else
309 else
310 super
310 super
311 end
311 end
312 end
312 end
313
313
314 def self.find_by_param(*args)
314 def self.find_by_param(*args)
315 self.find(*args)
315 self.find(*args)
316 end
316 end
317
317
318 alias :base_reload :reload
318 alias :base_reload :reload
319 def reload(*args)
319 def reload(*args)
320 @principals = nil
320 @principals = nil
321 @users = nil
321 @users = nil
322 @shared_versions = nil
322 @shared_versions = nil
323 @rolled_up_versions = nil
323 @rolled_up_versions = nil
324 @rolled_up_trackers = nil
324 @rolled_up_trackers = nil
325 @all_issue_custom_fields = nil
325 @all_issue_custom_fields = nil
326 @all_time_entry_custom_fields = nil
326 @all_time_entry_custom_fields = nil
327 @to_param = nil
327 @to_param = nil
328 @allowed_parents = nil
328 @allowed_parents = nil
329 @allowed_permissions = nil
329 @allowed_permissions = nil
330 @actions_allowed = nil
330 @actions_allowed = nil
331 @start_date = nil
331 @start_date = nil
332 @due_date = nil
332 @due_date = nil
333 @override_members = nil
333 @override_members = nil
334 @assignable_users = nil
334 @assignable_users = nil
335 base_reload(*args)
335 base_reload(*args)
336 end
336 end
337
337
338 def to_param
338 def to_param
339 if new_record?
339 if new_record?
340 nil
340 nil
341 else
341 else
342 # id is used for projects with a numeric identifier (compatibility)
342 # id is used for projects with a numeric identifier (compatibility)
343 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
343 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
344 end
344 end
345 end
345 end
346
346
347 def active?
347 def active?
348 self.status == STATUS_ACTIVE
348 self.status == STATUS_ACTIVE
349 end
349 end
350
350
351 def archived?
351 def archived?
352 self.status == STATUS_ARCHIVED
352 self.status == STATUS_ARCHIVED
353 end
353 end
354
354
355 # Archives the project and its descendants
355 # Archives the project and its descendants
356 def archive
356 def archive
357 # Check that there is no issue of a non descendant project that is assigned
357 # Check that there is no issue of a non descendant project that is assigned
358 # to one of the project or descendant versions
358 # to one of the project or descendant versions
359 version_ids = self_and_descendants.joins(:versions).pluck("#{Version.table_name}.id")
359 version_ids = self_and_descendants.joins(:versions).pluck("#{Version.table_name}.id")
360
360
361 if version_ids.any? &&
361 if version_ids.any? &&
362 Issue.
362 Issue.
363 includes(:project).
363 includes(:project).
364 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
364 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
365 where(:fixed_version_id => version_ids).
365 where(:fixed_version_id => version_ids).
366 exists?
366 exists?
367 return false
367 return false
368 end
368 end
369 Project.transaction do
369 Project.transaction do
370 archive!
370 archive!
371 end
371 end
372 true
372 true
373 end
373 end
374
374
375 # Unarchives the project
375 # Unarchives the project
376 # All its ancestors must be active
376 # All its ancestors must be active
377 def unarchive
377 def unarchive
378 return false if ancestors.detect {|a| !a.active?}
378 return false if ancestors.detect {|a| !a.active?}
379 update_attribute :status, STATUS_ACTIVE
379 update_attribute :status, STATUS_ACTIVE
380 end
380 end
381
381
382 def close
382 def close
383 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
383 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
384 end
384 end
385
385
386 def reopen
386 def reopen
387 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
387 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
388 end
388 end
389
389
390 # Returns an array of projects the project can be moved to
390 # Returns an array of projects the project can be moved to
391 # by the current user
391 # by the current user
392 def allowed_parents(user=User.current)
392 def allowed_parents(user=User.current)
393 return @allowed_parents if @allowed_parents
393 return @allowed_parents if @allowed_parents
394 @allowed_parents = Project.allowed_to(user, :add_subprojects).to_a
394 @allowed_parents = Project.allowed_to(user, :add_subprojects).to_a
395 @allowed_parents = @allowed_parents - self_and_descendants
395 @allowed_parents = @allowed_parents - self_and_descendants
396 if user.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
396 if user.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
397 @allowed_parents << nil
397 @allowed_parents << nil
398 end
398 end
399 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
399 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
400 @allowed_parents << parent
400 @allowed_parents << parent
401 end
401 end
402 @allowed_parents
402 @allowed_parents
403 end
403 end
404
404
405 # Sets the parent of the project with authorization check
405 # Sets the parent of the project with authorization check
406 def set_allowed_parent!(p)
406 def set_allowed_parent!(p)
407 ActiveSupport::Deprecation.warn "Project#set_allowed_parent! is deprecated and will be removed in Redmine 4, use #safe_attributes= instead."
407 ActiveSupport::Deprecation.warn "Project#set_allowed_parent! is deprecated and will be removed in Redmine 4, use #safe_attributes= instead."
408 p = p.id if p.is_a?(Project)
408 p = p.id if p.is_a?(Project)
409 send :safe_attributes, {:project_id => p}
409 send :safe_attributes, {:project_id => p}
410 save
410 save
411 end
411 end
412
412
413 # Sets the parent of the project and saves the project
413 # Sets the parent of the project and saves the project
414 # Argument can be either a Project, a String, a Fixnum or nil
414 # Argument can be either a Project, a String, a Fixnum or nil
415 def set_parent!(p)
415 def set_parent!(p)
416 if p.is_a?(Project)
416 if p.is_a?(Project)
417 self.parent = p
417 self.parent = p
418 else
418 else
419 self.parent_id = p
419 self.parent_id = p
420 end
420 end
421 save
421 save
422 end
422 end
423
423
424 # Returns a scope of the trackers used by the project and its active sub projects
424 # Returns a scope of the trackers used by the project and its active sub projects
425 def rolled_up_trackers(include_subprojects=true)
425 def rolled_up_trackers(include_subprojects=true)
426 if include_subprojects
426 if include_subprojects
427 @rolled_up_trackers ||= rolled_up_trackers_base_scope.
427 @rolled_up_trackers ||= rolled_up_trackers_base_scope.
428 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt)
428 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt)
429 else
429 else
430 rolled_up_trackers_base_scope.
430 rolled_up_trackers_base_scope.
431 where(:projects => {:id => id})
431 where(:projects => {:id => id})
432 end
432 end
433 end
433 end
434
434
435 def rolled_up_trackers_base_scope
435 def rolled_up_trackers_base_scope
436 Tracker.
436 Tracker.
437 joins(projects: :enabled_modules).
437 joins(projects: :enabled_modules).
438 where("#{Project.table_name}.status <> ?", STATUS_ARCHIVED).
438 where("#{Project.table_name}.status <> ?", STATUS_ARCHIVED).
439 where(:enabled_modules => {:name => 'issue_tracking'}).
439 where(:enabled_modules => {:name => 'issue_tracking'}).
440 uniq.
440 distinct.
441 sorted
441 sorted
442 end
442 end
443
443
444 # Closes open and locked project versions that are completed
444 # Closes open and locked project versions that are completed
445 def close_completed_versions
445 def close_completed_versions
446 Version.transaction do
446 Version.transaction do
447 versions.where(:status => %w(open locked)).each do |version|
447 versions.where(:status => %w(open locked)).each do |version|
448 if version.completed?
448 if version.completed?
449 version.update_attribute(:status, 'closed')
449 version.update_attribute(:status, 'closed')
450 end
450 end
451 end
451 end
452 end
452 end
453 end
453 end
454
454
455 # Returns a scope of the Versions on subprojects
455 # Returns a scope of the Versions on subprojects
456 def rolled_up_versions
456 def rolled_up_versions
457 @rolled_up_versions ||=
457 @rolled_up_versions ||=
458 Version.
458 Version.
459 joins(:project).
459 joins(:project).
460 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
460 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
461 end
461 end
462
462
463 # Returns a scope of the Versions used by the project
463 # Returns a scope of the Versions used by the project
464 def shared_versions
464 def shared_versions
465 if new_record?
465 if new_record?
466 Version.
466 Version.
467 joins(:project).
467 joins(:project).
468 preload(:project).
468 preload(:project).
469 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
469 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
470 else
470 else
471 @shared_versions ||= begin
471 @shared_versions ||= begin
472 r = root? ? self : root
472 r = root? ? self : root
473 Version.
473 Version.
474 joins(:project).
474 joins(:project).
475 preload(:project).
475 preload(:project).
476 where("#{Project.table_name}.id = #{id}" +
476 where("#{Project.table_name}.id = #{id}" +
477 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
477 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
478 " #{Version.table_name}.sharing = 'system'" +
478 " #{Version.table_name}.sharing = 'system'" +
479 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
479 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
480 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
480 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
481 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
481 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
482 "))")
482 "))")
483 end
483 end
484 end
484 end
485 end
485 end
486
486
487 # Returns a hash of project users grouped by role
487 # Returns a hash of project users grouped by role
488 def users_by_role
488 def users_by_role
489 members.includes(:user, :roles).inject({}) do |h, m|
489 members.includes(:user, :roles).inject({}) do |h, m|
490 m.roles.each do |r|
490 m.roles.each do |r|
491 h[r] ||= []
491 h[r] ||= []
492 h[r] << m.user
492 h[r] << m.user
493 end
493 end
494 h
494 h
495 end
495 end
496 end
496 end
497
497
498 # Adds user as a project member with the default role
498 # Adds user as a project member with the default role
499 # Used for when a non-admin user creates a project
499 # Used for when a non-admin user creates a project
500 def add_default_member(user)
500 def add_default_member(user)
501 role = Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
501 role = Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
502 member = Member.new(:project => self, :principal => user, :roles => [role])
502 member = Member.new(:project => self, :principal => user, :roles => [role])
503 self.members << member
503 self.members << member
504 member
504 member
505 end
505 end
506
506
507 # Deletes all project's members
507 # Deletes all project's members
508 def delete_all_members
508 def delete_all_members
509 me, mr = Member.table_name, MemberRole.table_name
509 me, mr = Member.table_name, MemberRole.table_name
510 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
510 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
511 Member.delete_all(['project_id = ?', id])
511 Member.delete_all(['project_id = ?', id])
512 end
512 end
513
513
514 # Return a Principal scope of users/groups issues can be assigned to
514 # Return a Principal scope of users/groups issues can be assigned to
515 def assignable_users(tracker=nil)
515 def assignable_users(tracker=nil)
516 return @assignable_users[tracker] if @assignable_users && @assignable_users[tracker]
516 return @assignable_users[tracker] if @assignable_users && @assignable_users[tracker]
517
517
518 types = ['User']
518 types = ['User']
519 types << 'Group' if Setting.issue_group_assignment?
519 types << 'Group' if Setting.issue_group_assignment?
520
520
521 scope = Principal.
521 scope = Principal.
522 active.
522 active.
523 joins(:members => :roles).
523 joins(:members => :roles).
524 where(:type => types, :members => {:project_id => id}, :roles => {:assignable => true}).
524 where(:type => types, :members => {:project_id => id}, :roles => {:assignable => true}).
525 uniq.
525 distinct.
526 sorted
526 sorted
527
527
528 if tracker
528 if tracker
529 # Rejects users that cannot the view the tracker
529 # Rejects users that cannot the view the tracker
530 roles = Role.where(:assignable => true).select {|role| role.permissions_tracker?(:view_issues, tracker)}
530 roles = Role.where(:assignable => true).select {|role| role.permissions_tracker?(:view_issues, tracker)}
531 scope = scope.where(:roles => {:id => roles.map(&:id)})
531 scope = scope.where(:roles => {:id => roles.map(&:id)})
532 end
532 end
533
533
534 @assignable_users ||= {}
534 @assignable_users ||= {}
535 @assignable_users[tracker] = scope
535 @assignable_users[tracker] = scope
536 end
536 end
537
537
538 # Returns the mail addresses of users that should be always notified on project events
538 # Returns the mail addresses of users that should be always notified on project events
539 def recipients
539 def recipients
540 notified_users.collect {|user| user.mail}
540 notified_users.collect {|user| user.mail}
541 end
541 end
542
542
543 # Returns the users that should be notified on project events
543 # Returns the users that should be notified on project events
544 def notified_users
544 def notified_users
545 # TODO: User part should be extracted to User#notify_about?
545 # TODO: User part should be extracted to User#notify_about?
546 members.preload(:principal).select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
546 members.preload(:principal).select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
547 end
547 end
548
548
549 # Returns a scope of all custom fields enabled for project issues
549 # Returns a scope of all custom fields enabled for project issues
550 # (explicitly associated custom fields and custom fields enabled for all projects)
550 # (explicitly associated custom fields and custom fields enabled for all projects)
551 def all_issue_custom_fields
551 def all_issue_custom_fields
552 if new_record?
552 if new_record?
553 @all_issue_custom_fields ||= IssueCustomField.
553 @all_issue_custom_fields ||= IssueCustomField.
554 sorted.
554 sorted.
555 where("is_for_all = ? OR id IN (?)", true, issue_custom_field_ids)
555 where("is_for_all = ? OR id IN (?)", true, issue_custom_field_ids)
556 else
556 else
557 @all_issue_custom_fields ||= IssueCustomField.
557 @all_issue_custom_fields ||= IssueCustomField.
558 sorted.
558 sorted.
559 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
559 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
560 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
560 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
561 " WHERE cfp.project_id = ?)", true, id)
561 " WHERE cfp.project_id = ?)", true, id)
562 end
562 end
563 end
563 end
564
564
565 def project
565 def project
566 self
566 self
567 end
567 end
568
568
569 def <=>(project)
569 def <=>(project)
570 name.casecmp(project.name)
570 name.casecmp(project.name)
571 end
571 end
572
572
573 def to_s
573 def to_s
574 name
574 name
575 end
575 end
576
576
577 # Returns a short description of the projects (first lines)
577 # Returns a short description of the projects (first lines)
578 def short_description(length = 255)
578 def short_description(length = 255)
579 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
579 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
580 end
580 end
581
581
582 def css_classes
582 def css_classes
583 s = 'project'
583 s = 'project'
584 s << ' root' if root?
584 s << ' root' if root?
585 s << ' child' if child?
585 s << ' child' if child?
586 s << (leaf? ? ' leaf' : ' parent')
586 s << (leaf? ? ' leaf' : ' parent')
587 unless active?
587 unless active?
588 if archived?
588 if archived?
589 s << ' archived'
589 s << ' archived'
590 else
590 else
591 s << ' closed'
591 s << ' closed'
592 end
592 end
593 end
593 end
594 s
594 s
595 end
595 end
596
596
597 # The earliest start date of a project, based on it's issues and versions
597 # The earliest start date of a project, based on it's issues and versions
598 def start_date
598 def start_date
599 @start_date ||= [
599 @start_date ||= [
600 issues.minimum('start_date'),
600 issues.minimum('start_date'),
601 shared_versions.minimum('effective_date'),
601 shared_versions.minimum('effective_date'),
602 Issue.fixed_version(shared_versions).minimum('start_date')
602 Issue.fixed_version(shared_versions).minimum('start_date')
603 ].compact.min
603 ].compact.min
604 end
604 end
605
605
606 # The latest due date of an issue or version
606 # The latest due date of an issue or version
607 def due_date
607 def due_date
608 @due_date ||= [
608 @due_date ||= [
609 issues.maximum('due_date'),
609 issues.maximum('due_date'),
610 shared_versions.maximum('effective_date'),
610 shared_versions.maximum('effective_date'),
611 Issue.fixed_version(shared_versions).maximum('due_date')
611 Issue.fixed_version(shared_versions).maximum('due_date')
612 ].compact.max
612 ].compact.max
613 end
613 end
614
614
615 def overdue?
615 def overdue?
616 active? && !due_date.nil? && (due_date < User.current.today)
616 active? && !due_date.nil? && (due_date < User.current.today)
617 end
617 end
618
618
619 # Returns the percent completed for this project, based on the
619 # Returns the percent completed for this project, based on the
620 # progress on it's versions.
620 # progress on it's versions.
621 def completed_percent(options={:include_subprojects => false})
621 def completed_percent(options={:include_subprojects => false})
622 if options.delete(:include_subprojects)
622 if options.delete(:include_subprojects)
623 total = self_and_descendants.collect(&:completed_percent).sum
623 total = self_and_descendants.collect(&:completed_percent).sum
624
624
625 total / self_and_descendants.count
625 total / self_and_descendants.count
626 else
626 else
627 if versions.count > 0
627 if versions.count > 0
628 total = versions.collect(&:completed_percent).sum
628 total = versions.collect(&:completed_percent).sum
629
629
630 total / versions.count
630 total / versions.count
631 else
631 else
632 100
632 100
633 end
633 end
634 end
634 end
635 end
635 end
636
636
637 # Return true if this project allows to do the specified action.
637 # Return true if this project allows to do the specified action.
638 # action can be:
638 # action can be:
639 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
639 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
640 # * a permission Symbol (eg. :edit_project)
640 # * a permission Symbol (eg. :edit_project)
641 def allows_to?(action)
641 def allows_to?(action)
642 if archived?
642 if archived?
643 # No action allowed on archived projects
643 # No action allowed on archived projects
644 return false
644 return false
645 end
645 end
646 unless active? || Redmine::AccessControl.read_action?(action)
646 unless active? || Redmine::AccessControl.read_action?(action)
647 # No write action allowed on closed projects
647 # No write action allowed on closed projects
648 return false
648 return false
649 end
649 end
650 # No action allowed on disabled modules
650 # No action allowed on disabled modules
651 if action.is_a? Hash
651 if action.is_a? Hash
652 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
652 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
653 else
653 else
654 allowed_permissions.include? action
654 allowed_permissions.include? action
655 end
655 end
656 end
656 end
657
657
658 # Return the enabled module with the given name
658 # Return the enabled module with the given name
659 # or nil if the module is not enabled for the project
659 # or nil if the module is not enabled for the project
660 def enabled_module(name)
660 def enabled_module(name)
661 name = name.to_s
661 name = name.to_s
662 enabled_modules.detect {|m| m.name == name}
662 enabled_modules.detect {|m| m.name == name}
663 end
663 end
664
664
665 # Return true if the module with the given name is enabled
665 # Return true if the module with the given name is enabled
666 def module_enabled?(name)
666 def module_enabled?(name)
667 enabled_module(name).present?
667 enabled_module(name).present?
668 end
668 end
669
669
670 def enabled_module_names=(module_names)
670 def enabled_module_names=(module_names)
671 if module_names && module_names.is_a?(Array)
671 if module_names && module_names.is_a?(Array)
672 module_names = module_names.collect(&:to_s).reject(&:blank?)
672 module_names = module_names.collect(&:to_s).reject(&:blank?)
673 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
673 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
674 else
674 else
675 enabled_modules.clear
675 enabled_modules.clear
676 end
676 end
677 end
677 end
678
678
679 # Returns an array of the enabled modules names
679 # Returns an array of the enabled modules names
680 def enabled_module_names
680 def enabled_module_names
681 enabled_modules.collect(&:name)
681 enabled_modules.collect(&:name)
682 end
682 end
683
683
684 # Enable a specific module
684 # Enable a specific module
685 #
685 #
686 # Examples:
686 # Examples:
687 # project.enable_module!(:issue_tracking)
687 # project.enable_module!(:issue_tracking)
688 # project.enable_module!("issue_tracking")
688 # project.enable_module!("issue_tracking")
689 def enable_module!(name)
689 def enable_module!(name)
690 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
690 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
691 end
691 end
692
692
693 # Disable a module if it exists
693 # Disable a module if it exists
694 #
694 #
695 # Examples:
695 # Examples:
696 # project.disable_module!(:issue_tracking)
696 # project.disable_module!(:issue_tracking)
697 # project.disable_module!("issue_tracking")
697 # project.disable_module!("issue_tracking")
698 # project.disable_module!(project.enabled_modules.first)
698 # project.disable_module!(project.enabled_modules.first)
699 def disable_module!(target)
699 def disable_module!(target)
700 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
700 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
701 target.destroy unless target.blank?
701 target.destroy unless target.blank?
702 end
702 end
703
703
704 safe_attributes 'name',
704 safe_attributes 'name',
705 'description',
705 'description',
706 'homepage',
706 'homepage',
707 'is_public',
707 'is_public',
708 'identifier',
708 'identifier',
709 'custom_field_values',
709 'custom_field_values',
710 'custom_fields',
710 'custom_fields',
711 'tracker_ids',
711 'tracker_ids',
712 'issue_custom_field_ids',
712 'issue_custom_field_ids',
713 'parent_id',
713 'parent_id',
714 'default_version_id'
714 'default_version_id'
715
715
716 safe_attributes 'enabled_module_names',
716 safe_attributes 'enabled_module_names',
717 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
717 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
718
718
719 safe_attributes 'inherit_members',
719 safe_attributes 'inherit_members',
720 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
720 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
721
721
722 def safe_attributes=(attrs, user=User.current)
722 def safe_attributes=(attrs, user=User.current)
723 return unless attrs.is_a?(Hash)
723 return unless attrs.is_a?(Hash)
724 attrs = attrs.deep_dup
724 attrs = attrs.deep_dup
725
725
726 @unallowed_parent_id = nil
726 @unallowed_parent_id = nil
727 if new_record? || attrs.key?('parent_id')
727 if new_record? || attrs.key?('parent_id')
728 parent_id_param = attrs['parent_id'].to_s
728 parent_id_param = attrs['parent_id'].to_s
729 if new_record? || parent_id_param != parent_id.to_s
729 if new_record? || parent_id_param != parent_id.to_s
730 p = parent_id_param.present? ? Project.find_by_id(parent_id_param) : nil
730 p = parent_id_param.present? ? Project.find_by_id(parent_id_param) : nil
731 unless allowed_parents(user).include?(p)
731 unless allowed_parents(user).include?(p)
732 attrs.delete('parent_id')
732 attrs.delete('parent_id')
733 @unallowed_parent_id = true
733 @unallowed_parent_id = true
734 end
734 end
735 end
735 end
736 end
736 end
737
737
738 super(attrs, user)
738 super(attrs, user)
739 end
739 end
740
740
741 # Returns an auto-generated project identifier based on the last identifier used
741 # Returns an auto-generated project identifier based on the last identifier used
742 def self.next_identifier
742 def self.next_identifier
743 p = Project.order('id DESC').first
743 p = Project.order('id DESC').first
744 p.nil? ? nil : p.identifier.to_s.succ
744 p.nil? ? nil : p.identifier.to_s.succ
745 end
745 end
746
746
747 # Copies and saves the Project instance based on the +project+.
747 # Copies and saves the Project instance based on the +project+.
748 # Duplicates the source project's:
748 # Duplicates the source project's:
749 # * Wiki
749 # * Wiki
750 # * Versions
750 # * Versions
751 # * Categories
751 # * Categories
752 # * Issues
752 # * Issues
753 # * Members
753 # * Members
754 # * Queries
754 # * Queries
755 #
755 #
756 # Accepts an +options+ argument to specify what to copy
756 # Accepts an +options+ argument to specify what to copy
757 #
757 #
758 # Examples:
758 # Examples:
759 # project.copy(1) # => copies everything
759 # project.copy(1) # => copies everything
760 # project.copy(1, :only => 'members') # => copies members only
760 # project.copy(1, :only => 'members') # => copies members only
761 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
761 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
762 def copy(project, options={})
762 def copy(project, options={})
763 project = project.is_a?(Project) ? project : Project.find(project)
763 project = project.is_a?(Project) ? project : Project.find(project)
764
764
765 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
765 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
766 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
766 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
767
767
768 Project.transaction do
768 Project.transaction do
769 if save
769 if save
770 reload
770 reload
771 to_be_copied.each do |name|
771 to_be_copied.each do |name|
772 send "copy_#{name}", project
772 send "copy_#{name}", project
773 end
773 end
774 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
774 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
775 save
775 save
776 else
776 else
777 false
777 false
778 end
778 end
779 end
779 end
780 end
780 end
781
781
782 def member_principals
782 def member_principals
783 ActiveSupport::Deprecation.warn "Project#member_principals is deprecated and will be removed in Redmine 4.0. Use #memberships.active instead."
783 ActiveSupport::Deprecation.warn "Project#member_principals is deprecated and will be removed in Redmine 4.0. Use #memberships.active instead."
784 memberships.active
784 memberships.active
785 end
785 end
786
786
787 # Returns a new unsaved Project instance with attributes copied from +project+
787 # Returns a new unsaved Project instance with attributes copied from +project+
788 def self.copy_from(project)
788 def self.copy_from(project)
789 project = project.is_a?(Project) ? project : Project.find(project)
789 project = project.is_a?(Project) ? project : Project.find(project)
790 # clear unique attributes
790 # clear unique attributes
791 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
791 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
792 copy = Project.new(attributes)
792 copy = Project.new(attributes)
793 copy.enabled_module_names = project.enabled_module_names
793 copy.enabled_module_names = project.enabled_module_names
794 copy.trackers = project.trackers
794 copy.trackers = project.trackers
795 copy.custom_values = project.custom_values.collect {|v| v.clone}
795 copy.custom_values = project.custom_values.collect {|v| v.clone}
796 copy.issue_custom_fields = project.issue_custom_fields
796 copy.issue_custom_fields = project.issue_custom_fields
797 copy
797 copy
798 end
798 end
799
799
800 # Yields the given block for each project with its level in the tree
800 # Yields the given block for each project with its level in the tree
801 def self.project_tree(projects, &block)
801 def self.project_tree(projects, &block)
802 ancestors = []
802 ancestors = []
803 projects.sort_by(&:lft).each do |project|
803 projects.sort_by(&:lft).each do |project|
804 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
804 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
805 ancestors.pop
805 ancestors.pop
806 end
806 end
807 yield project, ancestors.size
807 yield project, ancestors.size
808 ancestors << project
808 ancestors << project
809 end
809 end
810 end
810 end
811
811
812 private
812 private
813
813
814 def update_inherited_members
814 def update_inherited_members
815 if parent
815 if parent
816 if inherit_members? && !inherit_members_was
816 if inherit_members? && !inherit_members_was
817 remove_inherited_member_roles
817 remove_inherited_member_roles
818 add_inherited_member_roles
818 add_inherited_member_roles
819 elsif !inherit_members? && inherit_members_was
819 elsif !inherit_members? && inherit_members_was
820 remove_inherited_member_roles
820 remove_inherited_member_roles
821 end
821 end
822 end
822 end
823 end
823 end
824
824
825 def remove_inherited_member_roles
825 def remove_inherited_member_roles
826 member_roles = memberships.map(&:member_roles).flatten
826 member_roles = memberships.map(&:member_roles).flatten
827 member_role_ids = member_roles.map(&:id)
827 member_role_ids = member_roles.map(&:id)
828 member_roles.each do |member_role|
828 member_roles.each do |member_role|
829 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
829 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
830 member_role.destroy
830 member_role.destroy
831 end
831 end
832 end
832 end
833 end
833 end
834
834
835 def add_inherited_member_roles
835 def add_inherited_member_roles
836 if inherit_members? && parent
836 if inherit_members? && parent
837 parent.memberships.each do |parent_member|
837 parent.memberships.each do |parent_member|
838 member = Member.find_or_new(self.id, parent_member.user_id)
838 member = Member.find_or_new(self.id, parent_member.user_id)
839 parent_member.member_roles.each do |parent_member_role|
839 parent_member.member_roles.each do |parent_member_role|
840 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
840 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
841 end
841 end
842 member.save!
842 member.save!
843 end
843 end
844 memberships.reset
844 memberships.reset
845 end
845 end
846 end
846 end
847
847
848 def update_versions_from_hierarchy_change
848 def update_versions_from_hierarchy_change
849 Issue.update_versions_from_hierarchy_change(self)
849 Issue.update_versions_from_hierarchy_change(self)
850 end
850 end
851
851
852 def validate_parent
852 def validate_parent
853 if @unallowed_parent_id
853 if @unallowed_parent_id
854 errors.add(:parent_id, :invalid)
854 errors.add(:parent_id, :invalid)
855 elsif parent_id_changed?
855 elsif parent_id_changed?
856 unless parent.nil? || (parent.active? && move_possible?(parent))
856 unless parent.nil? || (parent.active? && move_possible?(parent))
857 errors.add(:parent_id, :invalid)
857 errors.add(:parent_id, :invalid)
858 end
858 end
859 end
859 end
860 end
860 end
861
861
862 # Copies wiki from +project+
862 # Copies wiki from +project+
863 def copy_wiki(project)
863 def copy_wiki(project)
864 # Check that the source project has a wiki first
864 # Check that the source project has a wiki first
865 unless project.wiki.nil?
865 unless project.wiki.nil?
866 wiki = self.wiki || Wiki.new
866 wiki = self.wiki || Wiki.new
867 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
867 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
868 wiki_pages_map = {}
868 wiki_pages_map = {}
869 project.wiki.pages.each do |page|
869 project.wiki.pages.each do |page|
870 # Skip pages without content
870 # Skip pages without content
871 next if page.content.nil?
871 next if page.content.nil?
872 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
872 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
873 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
873 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
874 new_wiki_page.content = new_wiki_content
874 new_wiki_page.content = new_wiki_content
875 wiki.pages << new_wiki_page
875 wiki.pages << new_wiki_page
876 wiki_pages_map[page.id] = new_wiki_page
876 wiki_pages_map[page.id] = new_wiki_page
877 end
877 end
878
878
879 self.wiki = wiki
879 self.wiki = wiki
880 wiki.save
880 wiki.save
881 # Reproduce page hierarchy
881 # Reproduce page hierarchy
882 project.wiki.pages.each do |page|
882 project.wiki.pages.each do |page|
883 if page.parent_id && wiki_pages_map[page.id]
883 if page.parent_id && wiki_pages_map[page.id]
884 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
884 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
885 wiki_pages_map[page.id].save
885 wiki_pages_map[page.id].save
886 end
886 end
887 end
887 end
888 end
888 end
889 end
889 end
890
890
891 # Copies versions from +project+
891 # Copies versions from +project+
892 def copy_versions(project)
892 def copy_versions(project)
893 project.versions.each do |version|
893 project.versions.each do |version|
894 new_version = Version.new
894 new_version = Version.new
895 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
895 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
896 self.versions << new_version
896 self.versions << new_version
897 end
897 end
898 end
898 end
899
899
900 # Copies issue categories from +project+
900 # Copies issue categories from +project+
901 def copy_issue_categories(project)
901 def copy_issue_categories(project)
902 project.issue_categories.each do |issue_category|
902 project.issue_categories.each do |issue_category|
903 new_issue_category = IssueCategory.new
903 new_issue_category = IssueCategory.new
904 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
904 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
905 self.issue_categories << new_issue_category
905 self.issue_categories << new_issue_category
906 end
906 end
907 end
907 end
908
908
909 # Copies issues from +project+
909 # Copies issues from +project+
910 def copy_issues(project)
910 def copy_issues(project)
911 # Stores the source issue id as a key and the copied issues as the
911 # Stores the source issue id as a key and the copied issues as the
912 # value. Used to map the two together for issue relations.
912 # value. Used to map the two together for issue relations.
913 issues_map = {}
913 issues_map = {}
914
914
915 # Store status and reopen locked/closed versions
915 # Store status and reopen locked/closed versions
916 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
916 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
917 version_statuses.each do |version, status|
917 version_statuses.each do |version, status|
918 version.update_attribute :status, 'open'
918 version.update_attribute :status, 'open'
919 end
919 end
920
920
921 # Get issues sorted by root_id, lft so that parent issues
921 # Get issues sorted by root_id, lft so that parent issues
922 # get copied before their children
922 # get copied before their children
923 project.issues.reorder('root_id, lft').each do |issue|
923 project.issues.reorder('root_id, lft').each do |issue|
924 new_issue = Issue.new
924 new_issue = Issue.new
925 new_issue.copy_from(issue, :subtasks => false, :link => false)
925 new_issue.copy_from(issue, :subtasks => false, :link => false)
926 new_issue.project = self
926 new_issue.project = self
927 # Changing project resets the custom field values
927 # Changing project resets the custom field values
928 # TODO: handle this in Issue#project=
928 # TODO: handle this in Issue#project=
929 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
929 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
930 # Reassign fixed_versions by name, since names are unique per project
930 # Reassign fixed_versions by name, since names are unique per project
931 if issue.fixed_version && issue.fixed_version.project == project
931 if issue.fixed_version && issue.fixed_version.project == project
932 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
932 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
933 end
933 end
934 # Reassign version custom field values
934 # Reassign version custom field values
935 new_issue.custom_field_values.each do |custom_value|
935 new_issue.custom_field_values.each do |custom_value|
936 if custom_value.custom_field.field_format == 'version' && custom_value.value.present?
936 if custom_value.custom_field.field_format == 'version' && custom_value.value.present?
937 versions = Version.where(:id => custom_value.value).to_a
937 versions = Version.where(:id => custom_value.value).to_a
938 new_value = versions.map do |version|
938 new_value = versions.map do |version|
939 if version.project == project
939 if version.project == project
940 self.versions.detect {|v| v.name == version.name}.try(:id)
940 self.versions.detect {|v| v.name == version.name}.try(:id)
941 else
941 else
942 version.id
942 version.id
943 end
943 end
944 end
944 end
945 new_value.compact!
945 new_value.compact!
946 new_value = new_value.first unless custom_value.custom_field.multiple?
946 new_value = new_value.first unless custom_value.custom_field.multiple?
947 custom_value.value = new_value
947 custom_value.value = new_value
948 end
948 end
949 end
949 end
950 # Reassign the category by name, since names are unique per project
950 # Reassign the category by name, since names are unique per project
951 if issue.category
951 if issue.category
952 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
952 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
953 end
953 end
954 # Parent issue
954 # Parent issue
955 if issue.parent_id
955 if issue.parent_id
956 if copied_parent = issues_map[issue.parent_id]
956 if copied_parent = issues_map[issue.parent_id]
957 new_issue.parent_issue_id = copied_parent.id
957 new_issue.parent_issue_id = copied_parent.id
958 end
958 end
959 end
959 end
960
960
961 self.issues << new_issue
961 self.issues << new_issue
962 if new_issue.new_record?
962 if new_issue.new_record?
963 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info?
963 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info?
964 else
964 else
965 issues_map[issue.id] = new_issue unless new_issue.new_record?
965 issues_map[issue.id] = new_issue unless new_issue.new_record?
966 end
966 end
967 end
967 end
968
968
969 # Restore locked/closed version statuses
969 # Restore locked/closed version statuses
970 version_statuses.each do |version, status|
970 version_statuses.each do |version, status|
971 version.update_attribute :status, status
971 version.update_attribute :status, status
972 end
972 end
973
973
974 # Relations after in case issues related each other
974 # Relations after in case issues related each other
975 project.issues.each do |issue|
975 project.issues.each do |issue|
976 new_issue = issues_map[issue.id]
976 new_issue = issues_map[issue.id]
977 unless new_issue
977 unless new_issue
978 # Issue was not copied
978 # Issue was not copied
979 next
979 next
980 end
980 end
981
981
982 # Relations
982 # Relations
983 issue.relations_from.each do |source_relation|
983 issue.relations_from.each do |source_relation|
984 new_issue_relation = IssueRelation.new
984 new_issue_relation = IssueRelation.new
985 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
985 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
986 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
986 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
987 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
987 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
988 new_issue_relation.issue_to = source_relation.issue_to
988 new_issue_relation.issue_to = source_relation.issue_to
989 end
989 end
990 new_issue.relations_from << new_issue_relation
990 new_issue.relations_from << new_issue_relation
991 end
991 end
992
992
993 issue.relations_to.each do |source_relation|
993 issue.relations_to.each do |source_relation|
994 new_issue_relation = IssueRelation.new
994 new_issue_relation = IssueRelation.new
995 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
995 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
996 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
996 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
997 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
997 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
998 new_issue_relation.issue_from = source_relation.issue_from
998 new_issue_relation.issue_from = source_relation.issue_from
999 end
999 end
1000 new_issue.relations_to << new_issue_relation
1000 new_issue.relations_to << new_issue_relation
1001 end
1001 end
1002 end
1002 end
1003 end
1003 end
1004
1004
1005 # Copies members from +project+
1005 # Copies members from +project+
1006 def copy_members(project)
1006 def copy_members(project)
1007 # Copy users first, then groups to handle members with inherited and given roles
1007 # Copy users first, then groups to handle members with inherited and given roles
1008 members_to_copy = []
1008 members_to_copy = []
1009 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
1009 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
1010 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
1010 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
1011
1011
1012 members_to_copy.each do |member|
1012 members_to_copy.each do |member|
1013 new_member = Member.new
1013 new_member = Member.new
1014 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
1014 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
1015 # only copy non inherited roles
1015 # only copy non inherited roles
1016 # inherited roles will be added when copying the group membership
1016 # inherited roles will be added when copying the group membership
1017 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
1017 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
1018 next if role_ids.empty?
1018 next if role_ids.empty?
1019 new_member.role_ids = role_ids
1019 new_member.role_ids = role_ids
1020 new_member.project = self
1020 new_member.project = self
1021 self.members << new_member
1021 self.members << new_member
1022 end
1022 end
1023 end
1023 end
1024
1024
1025 # Copies queries from +project+
1025 # Copies queries from +project+
1026 def copy_queries(project)
1026 def copy_queries(project)
1027 project.queries.each do |query|
1027 project.queries.each do |query|
1028 new_query = IssueQuery.new
1028 new_query = IssueQuery.new
1029 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
1029 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
1030 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
1030 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
1031 new_query.project = self
1031 new_query.project = self
1032 new_query.user_id = query.user_id
1032 new_query.user_id = query.user_id
1033 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
1033 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
1034 self.queries << new_query
1034 self.queries << new_query
1035 end
1035 end
1036 end
1036 end
1037
1037
1038 # Copies boards from +project+
1038 # Copies boards from +project+
1039 def copy_boards(project)
1039 def copy_boards(project)
1040 project.boards.each do |board|
1040 project.boards.each do |board|
1041 new_board = Board.new
1041 new_board = Board.new
1042 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
1042 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
1043 new_board.project = self
1043 new_board.project = self
1044 self.boards << new_board
1044 self.boards << new_board
1045 end
1045 end
1046 end
1046 end
1047
1047
1048 def allowed_permissions
1048 def allowed_permissions
1049 @allowed_permissions ||= begin
1049 @allowed_permissions ||= begin
1050 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
1050 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
1051 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
1051 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
1052 end
1052 end
1053 end
1053 end
1054
1054
1055 def allowed_actions
1055 def allowed_actions
1056 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
1056 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
1057 end
1057 end
1058
1058
1059 # Archives subprojects recursively
1059 # Archives subprojects recursively
1060 def archive!
1060 def archive!
1061 children.each do |subproject|
1061 children.each do |subproject|
1062 subproject.send :archive!
1062 subproject.send :archive!
1063 end
1063 end
1064 update_attribute :status, STATUS_ARCHIVED
1064 update_attribute :status, STATUS_ARCHIVED
1065 end
1065 end
1066 end
1066 end
@@ -1,510 +1,510
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class ScmFetchError < Exception; end
18 class ScmFetchError < Exception; end
19
19
20 class Repository < ActiveRecord::Base
20 class Repository < ActiveRecord::Base
21 include Redmine::Ciphering
21 include Redmine::Ciphering
22 include Redmine::SafeAttributes
22 include Redmine::SafeAttributes
23
23
24 # Maximum length for repository identifiers
24 # Maximum length for repository identifiers
25 IDENTIFIER_MAX_LENGTH = 255
25 IDENTIFIER_MAX_LENGTH = 255
26
26
27 belongs_to :project
27 belongs_to :project
28 has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
28 has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
29 has_many :filechanges, :class_name => 'Change', :through => :changesets
29 has_many :filechanges, :class_name => 'Change', :through => :changesets
30
30
31 serialize :extra_info
31 serialize :extra_info
32
32
33 before_validation :normalize_identifier
33 before_validation :normalize_identifier
34 before_save :check_default
34 before_save :check_default
35
35
36 # Raw SQL to delete changesets and changes in the database
36 # Raw SQL to delete changesets and changes in the database
37 # has_many :changesets, :dependent => :destroy is too slow for big repositories
37 # has_many :changesets, :dependent => :destroy is too slow for big repositories
38 before_destroy :clear_changesets
38 before_destroy :clear_changesets
39
39
40 validates_length_of :password, :maximum => 255, :allow_nil => true
40 validates_length_of :password, :maximum => 255, :allow_nil => true
41 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
41 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
42 validates_uniqueness_of :identifier, :scope => :project_id
42 validates_uniqueness_of :identifier, :scope => :project_id
43 validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
43 validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
44 # donwcase letters, digits, dashes, underscores but not digits only
44 # donwcase letters, digits, dashes, underscores but not digits only
45 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
45 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
46 # Checks if the SCM is enabled when creating a repository
46 # Checks if the SCM is enabled when creating a repository
47 validate :repo_create_validation, :on => :create
47 validate :repo_create_validation, :on => :create
48 validate :validate_repository_path
48 validate :validate_repository_path
49 attr_protected :id
49 attr_protected :id
50
50
51 safe_attributes 'identifier',
51 safe_attributes 'identifier',
52 'login',
52 'login',
53 'password',
53 'password',
54 'path_encoding',
54 'path_encoding',
55 'log_encoding',
55 'log_encoding',
56 'is_default'
56 'is_default'
57
57
58 safe_attributes 'url',
58 safe_attributes 'url',
59 :if => lambda {|repository, user| repository.new_record?}
59 :if => lambda {|repository, user| repository.new_record?}
60
60
61 def repo_create_validation
61 def repo_create_validation
62 unless Setting.enabled_scm.include?(self.class.name.demodulize)
62 unless Setting.enabled_scm.include?(self.class.name.demodulize)
63 errors.add(:type, :invalid)
63 errors.add(:type, :invalid)
64 end
64 end
65 end
65 end
66
66
67 def self.human_attribute_name(attribute_key_name, *args)
67 def self.human_attribute_name(attribute_key_name, *args)
68 attr_name = attribute_key_name.to_s
68 attr_name = attribute_key_name.to_s
69 if attr_name == "log_encoding"
69 if attr_name == "log_encoding"
70 attr_name = "commit_logs_encoding"
70 attr_name = "commit_logs_encoding"
71 end
71 end
72 super(attr_name, *args)
72 super(attr_name, *args)
73 end
73 end
74
74
75 # Removes leading and trailing whitespace
75 # Removes leading and trailing whitespace
76 def url=(arg)
76 def url=(arg)
77 write_attribute(:url, arg ? arg.to_s.strip : nil)
77 write_attribute(:url, arg ? arg.to_s.strip : nil)
78 end
78 end
79
79
80 # Removes leading and trailing whitespace
80 # Removes leading and trailing whitespace
81 def root_url=(arg)
81 def root_url=(arg)
82 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
82 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
83 end
83 end
84
84
85 def password
85 def password
86 read_ciphered_attribute(:password)
86 read_ciphered_attribute(:password)
87 end
87 end
88
88
89 def password=(arg)
89 def password=(arg)
90 write_ciphered_attribute(:password, arg)
90 write_ciphered_attribute(:password, arg)
91 end
91 end
92
92
93 def scm_adapter
93 def scm_adapter
94 self.class.scm_adapter_class
94 self.class.scm_adapter_class
95 end
95 end
96
96
97 def scm
97 def scm
98 unless @scm
98 unless @scm
99 @scm = self.scm_adapter.new(url, root_url,
99 @scm = self.scm_adapter.new(url, root_url,
100 login, password, path_encoding)
100 login, password, path_encoding)
101 if root_url.blank? && @scm.root_url.present?
101 if root_url.blank? && @scm.root_url.present?
102 update_attribute(:root_url, @scm.root_url)
102 update_attribute(:root_url, @scm.root_url)
103 end
103 end
104 end
104 end
105 @scm
105 @scm
106 end
106 end
107
107
108 def scm_name
108 def scm_name
109 self.class.scm_name
109 self.class.scm_name
110 end
110 end
111
111
112 def name
112 def name
113 if identifier.present?
113 if identifier.present?
114 identifier
114 identifier
115 elsif is_default?
115 elsif is_default?
116 l(:field_repository_is_default)
116 l(:field_repository_is_default)
117 else
117 else
118 scm_name
118 scm_name
119 end
119 end
120 end
120 end
121
121
122 def identifier=(identifier)
122 def identifier=(identifier)
123 super unless identifier_frozen?
123 super unless identifier_frozen?
124 end
124 end
125
125
126 def identifier_frozen?
126 def identifier_frozen?
127 errors[:identifier].blank? && !(new_record? || identifier.blank?)
127 errors[:identifier].blank? && !(new_record? || identifier.blank?)
128 end
128 end
129
129
130 def identifier_param
130 def identifier_param
131 if is_default?
131 if is_default?
132 nil
132 nil
133 elsif identifier.present?
133 elsif identifier.present?
134 identifier
134 identifier
135 else
135 else
136 id.to_s
136 id.to_s
137 end
137 end
138 end
138 end
139
139
140 def <=>(repository)
140 def <=>(repository)
141 if is_default?
141 if is_default?
142 -1
142 -1
143 elsif repository.is_default?
143 elsif repository.is_default?
144 1
144 1
145 else
145 else
146 identifier.to_s <=> repository.identifier.to_s
146 identifier.to_s <=> repository.identifier.to_s
147 end
147 end
148 end
148 end
149
149
150 def self.find_by_identifier_param(param)
150 def self.find_by_identifier_param(param)
151 if param.to_s =~ /^\d+$/
151 if param.to_s =~ /^\d+$/
152 find_by_id(param)
152 find_by_id(param)
153 else
153 else
154 find_by_identifier(param)
154 find_by_identifier(param)
155 end
155 end
156 end
156 end
157
157
158 # TODO: should return an empty hash instead of nil to avoid many ||{}
158 # TODO: should return an empty hash instead of nil to avoid many ||{}
159 def extra_info
159 def extra_info
160 h = read_attribute(:extra_info)
160 h = read_attribute(:extra_info)
161 h.is_a?(Hash) ? h : nil
161 h.is_a?(Hash) ? h : nil
162 end
162 end
163
163
164 def merge_extra_info(arg)
164 def merge_extra_info(arg)
165 h = extra_info || {}
165 h = extra_info || {}
166 return h if arg.nil?
166 return h if arg.nil?
167 h.merge!(arg)
167 h.merge!(arg)
168 write_attribute(:extra_info, h)
168 write_attribute(:extra_info, h)
169 end
169 end
170
170
171 def report_last_commit
171 def report_last_commit
172 true
172 true
173 end
173 end
174
174
175 def supports_cat?
175 def supports_cat?
176 scm.supports_cat?
176 scm.supports_cat?
177 end
177 end
178
178
179 def supports_annotate?
179 def supports_annotate?
180 scm.supports_annotate?
180 scm.supports_annotate?
181 end
181 end
182
182
183 def supports_all_revisions?
183 def supports_all_revisions?
184 true
184 true
185 end
185 end
186
186
187 def supports_directory_revisions?
187 def supports_directory_revisions?
188 false
188 false
189 end
189 end
190
190
191 def supports_revision_graph?
191 def supports_revision_graph?
192 false
192 false
193 end
193 end
194
194
195 def entry(path=nil, identifier=nil)
195 def entry(path=nil, identifier=nil)
196 scm.entry(path, identifier)
196 scm.entry(path, identifier)
197 end
197 end
198
198
199 def scm_entries(path=nil, identifier=nil)
199 def scm_entries(path=nil, identifier=nil)
200 scm.entries(path, identifier)
200 scm.entries(path, identifier)
201 end
201 end
202 protected :scm_entries
202 protected :scm_entries
203
203
204 def entries(path=nil, identifier=nil)
204 def entries(path=nil, identifier=nil)
205 entries = scm_entries(path, identifier)
205 entries = scm_entries(path, identifier)
206 load_entries_changesets(entries)
206 load_entries_changesets(entries)
207 entries
207 entries
208 end
208 end
209
209
210 def branches
210 def branches
211 scm.branches
211 scm.branches
212 end
212 end
213
213
214 def tags
214 def tags
215 scm.tags
215 scm.tags
216 end
216 end
217
217
218 def default_branch
218 def default_branch
219 nil
219 nil
220 end
220 end
221
221
222 def properties(path, identifier=nil)
222 def properties(path, identifier=nil)
223 scm.properties(path, identifier)
223 scm.properties(path, identifier)
224 end
224 end
225
225
226 def cat(path, identifier=nil)
226 def cat(path, identifier=nil)
227 scm.cat(path, identifier)
227 scm.cat(path, identifier)
228 end
228 end
229
229
230 def diff(path, rev, rev_to)
230 def diff(path, rev, rev_to)
231 scm.diff(path, rev, rev_to)
231 scm.diff(path, rev, rev_to)
232 end
232 end
233
233
234 def diff_format_revisions(cs, cs_to, sep=':')
234 def diff_format_revisions(cs, cs_to, sep=':')
235 text = ""
235 text = ""
236 text << cs_to.format_identifier + sep if cs_to
236 text << cs_to.format_identifier + sep if cs_to
237 text << cs.format_identifier if cs
237 text << cs.format_identifier if cs
238 text
238 text
239 end
239 end
240
240
241 # Returns a path relative to the url of the repository
241 # Returns a path relative to the url of the repository
242 def relative_path(path)
242 def relative_path(path)
243 path
243 path
244 end
244 end
245
245
246 # Finds and returns a revision with a number or the beginning of a hash
246 # Finds and returns a revision with a number or the beginning of a hash
247 def find_changeset_by_name(name)
247 def find_changeset_by_name(name)
248 return nil if name.blank?
248 return nil if name.blank?
249 s = name.to_s
249 s = name.to_s
250 if s.match(/^\d*$/)
250 if s.match(/^\d*$/)
251 changesets.where("revision = ?", s).first
251 changesets.where("revision = ?", s).first
252 else
252 else
253 changesets.where("revision LIKE ?", s + '%').first
253 changesets.where("revision LIKE ?", s + '%').first
254 end
254 end
255 end
255 end
256
256
257 def latest_changeset
257 def latest_changeset
258 @latest_changeset ||= changesets.first
258 @latest_changeset ||= changesets.first
259 end
259 end
260
260
261 # Returns the latest changesets for +path+
261 # Returns the latest changesets for +path+
262 # Default behaviour is to search in cached changesets
262 # Default behaviour is to search in cached changesets
263 def latest_changesets(path, rev, limit=10)
263 def latest_changesets(path, rev, limit=10)
264 if path.blank?
264 if path.blank?
265 changesets.
265 changesets.
266 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
266 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
267 limit(limit).
267 limit(limit).
268 preload(:user).
268 preload(:user).
269 to_a
269 to_a
270 else
270 else
271 filechanges.
271 filechanges.
272 where("path = ?", path.with_leading_slash).
272 where("path = ?", path.with_leading_slash).
273 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
273 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
274 limit(limit).
274 limit(limit).
275 preload(:changeset => :user).
275 preload(:changeset => :user).
276 collect(&:changeset)
276 collect(&:changeset)
277 end
277 end
278 end
278 end
279
279
280 def scan_changesets_for_issue_ids
280 def scan_changesets_for_issue_ids
281 self.changesets.each(&:scan_comment_for_issue_ids)
281 self.changesets.each(&:scan_comment_for_issue_ids)
282 end
282 end
283
283
284 # Returns an array of committers usernames and associated user_id
284 # Returns an array of committers usernames and associated user_id
285 def committers
285 def committers
286 @committers ||= Changeset.where(:repository_id => id).uniq.pluck(:committer, :user_id)
286 @committers ||= Changeset.where(:repository_id => id).distinct.pluck(:committer, :user_id)
287 end
287 end
288
288
289 # Maps committers username to a user ids
289 # Maps committers username to a user ids
290 def committer_ids=(h)
290 def committer_ids=(h)
291 if h.is_a?(Hash)
291 if h.is_a?(Hash)
292 committers.each do |committer, user_id|
292 committers.each do |committer, user_id|
293 new_user_id = h[committer]
293 new_user_id = h[committer]
294 if new_user_id && (new_user_id.to_i != user_id.to_i)
294 if new_user_id && (new_user_id.to_i != user_id.to_i)
295 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
295 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
296 Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
296 Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
297 update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
297 update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
298 end
298 end
299 end
299 end
300 @committers = nil
300 @committers = nil
301 @found_committer_users = nil
301 @found_committer_users = nil
302 true
302 true
303 else
303 else
304 false
304 false
305 end
305 end
306 end
306 end
307
307
308 # Returns the Redmine User corresponding to the given +committer+
308 # Returns the Redmine User corresponding to the given +committer+
309 # It will return nil if the committer is not yet mapped and if no User
309 # It will return nil if the committer is not yet mapped and if no User
310 # with the same username or email was found
310 # with the same username or email was found
311 def find_committer_user(committer)
311 def find_committer_user(committer)
312 unless committer.blank?
312 unless committer.blank?
313 @found_committer_users ||= {}
313 @found_committer_users ||= {}
314 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
314 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
315
315
316 user = nil
316 user = nil
317 c = changesets.where(:committer => committer).
317 c = changesets.where(:committer => committer).
318 includes(:user).references(:user).first
318 includes(:user).references(:user).first
319 if c && c.user
319 if c && c.user
320 user = c.user
320 user = c.user
321 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
321 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
322 username, email = $1.strip, $3
322 username, email = $1.strip, $3
323 u = User.find_by_login(username)
323 u = User.find_by_login(username)
324 u ||= User.find_by_mail(email) unless email.blank?
324 u ||= User.find_by_mail(email) unless email.blank?
325 user = u
325 user = u
326 end
326 end
327 @found_committer_users[committer] = user
327 @found_committer_users[committer] = user
328 user
328 user
329 end
329 end
330 end
330 end
331
331
332 def repo_log_encoding
332 def repo_log_encoding
333 encoding = log_encoding.to_s.strip
333 encoding = log_encoding.to_s.strip
334 encoding.blank? ? 'UTF-8' : encoding
334 encoding.blank? ? 'UTF-8' : encoding
335 end
335 end
336
336
337 # Fetches new changesets for all repositories of active projects
337 # Fetches new changesets for all repositories of active projects
338 # Can be called periodically by an external script
338 # Can be called periodically by an external script
339 # eg. ruby script/runner "Repository.fetch_changesets"
339 # eg. ruby script/runner "Repository.fetch_changesets"
340 def self.fetch_changesets
340 def self.fetch_changesets
341 Project.active.has_module(:repository).all.each do |project|
341 Project.active.has_module(:repository).all.each do |project|
342 project.repositories.each do |repository|
342 project.repositories.each do |repository|
343 begin
343 begin
344 repository.fetch_changesets
344 repository.fetch_changesets
345 rescue Redmine::Scm::Adapters::CommandFailed => e
345 rescue Redmine::Scm::Adapters::CommandFailed => e
346 logger.error "scm: error during fetching changesets: #{e.message}"
346 logger.error "scm: error during fetching changesets: #{e.message}"
347 end
347 end
348 end
348 end
349 end
349 end
350 end
350 end
351
351
352 # scan changeset comments to find related and fixed issues for all repositories
352 # scan changeset comments to find related and fixed issues for all repositories
353 def self.scan_changesets_for_issue_ids
353 def self.scan_changesets_for_issue_ids
354 all.each(&:scan_changesets_for_issue_ids)
354 all.each(&:scan_changesets_for_issue_ids)
355 end
355 end
356
356
357 def self.scm_name
357 def self.scm_name
358 'Abstract'
358 'Abstract'
359 end
359 end
360
360
361 def self.available_scm
361 def self.available_scm
362 subclasses.collect {|klass| [klass.scm_name, klass.name]}
362 subclasses.collect {|klass| [klass.scm_name, klass.name]}
363 end
363 end
364
364
365 def self.factory(klass_name, *args)
365 def self.factory(klass_name, *args)
366 klass = "Repository::#{klass_name}".constantize
366 klass = "Repository::#{klass_name}".constantize
367 klass.new(*args)
367 klass.new(*args)
368 rescue
368 rescue
369 nil
369 nil
370 end
370 end
371
371
372 def self.scm_adapter_class
372 def self.scm_adapter_class
373 nil
373 nil
374 end
374 end
375
375
376 def self.scm_command
376 def self.scm_command
377 ret = ""
377 ret = ""
378 begin
378 begin
379 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
379 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
380 rescue Exception => e
380 rescue Exception => e
381 logger.error "scm: error during get command: #{e.message}"
381 logger.error "scm: error during get command: #{e.message}"
382 end
382 end
383 ret
383 ret
384 end
384 end
385
385
386 def self.scm_version_string
386 def self.scm_version_string
387 ret = ""
387 ret = ""
388 begin
388 begin
389 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
389 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
390 rescue Exception => e
390 rescue Exception => e
391 logger.error "scm: error during get version string: #{e.message}"
391 logger.error "scm: error during get version string: #{e.message}"
392 end
392 end
393 ret
393 ret
394 end
394 end
395
395
396 def self.scm_available
396 def self.scm_available
397 ret = false
397 ret = false
398 begin
398 begin
399 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
399 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
400 rescue Exception => e
400 rescue Exception => e
401 logger.error "scm: error during get scm available: #{e.message}"
401 logger.error "scm: error during get scm available: #{e.message}"
402 end
402 end
403 ret
403 ret
404 end
404 end
405
405
406 def set_as_default?
406 def set_as_default?
407 new_record? && project && Repository.where(:project_id => project.id).empty?
407 new_record? && project && Repository.where(:project_id => project.id).empty?
408 end
408 end
409
409
410 # Returns a hash with statistics by author in the following form:
410 # Returns a hash with statistics by author in the following form:
411 # {
411 # {
412 # "John Smith" => { :commits => 45, :changes => 324 },
412 # "John Smith" => { :commits => 45, :changes => 324 },
413 # "Bob" => { ... }
413 # "Bob" => { ... }
414 # }
414 # }
415 #
415 #
416 # Notes:
416 # Notes:
417 # - this hash honnors the users mapping defined for the repository
417 # - this hash honnors the users mapping defined for the repository
418 def stats_by_author
418 def stats_by_author
419 commits = Changeset.where("repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
419 commits = Changeset.where("repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
420
420
421 #TODO: restore ordering ; this line probably never worked
421 #TODO: restore ordering ; this line probably never worked
422 #commits.to_a.sort! {|x, y| x.last <=> y.last}
422 #commits.to_a.sort! {|x, y| x.last <=> y.last}
423
423
424 changes = Change.joins(:changeset).where("#{Changeset.table_name}.repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
424 changes = Change.joins(:changeset).where("#{Changeset.table_name}.repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
425
425
426 user_ids = changesets.map(&:user_id).compact.uniq
426 user_ids = changesets.map(&:user_id).compact.uniq
427 authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
427 authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
428 memo[user.id] = user.to_s
428 memo[user.id] = user.to_s
429 memo
429 memo
430 end
430 end
431
431
432 (commits + changes).inject({}) do |hash, element|
432 (commits + changes).inject({}) do |hash, element|
433 mapped_name = element.committer
433 mapped_name = element.committer
434 if username = authors_names[element.user_id.to_i]
434 if username = authors_names[element.user_id.to_i]
435 mapped_name = username
435 mapped_name = username
436 end
436 end
437 hash[mapped_name] ||= { :commits_count => 0, :changes_count => 0 }
437 hash[mapped_name] ||= { :commits_count => 0, :changes_count => 0 }
438 if element.is_a?(Changeset)
438 if element.is_a?(Changeset)
439 hash[mapped_name][:commits_count] += element.count.to_i
439 hash[mapped_name][:commits_count] += element.count.to_i
440 else
440 else
441 hash[mapped_name][:changes_count] += element.count.to_i
441 hash[mapped_name][:changes_count] += element.count.to_i
442 end
442 end
443 hash
443 hash
444 end
444 end
445 end
445 end
446
446
447 # Returns a scope of changesets that come from the same commit as the given changeset
447 # Returns a scope of changesets that come from the same commit as the given changeset
448 # in different repositories that point to the same backend
448 # in different repositories that point to the same backend
449 def same_commits_in_scope(scope, changeset)
449 def same_commits_in_scope(scope, changeset)
450 scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
450 scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
451 if changeset.scmid.present?
451 if changeset.scmid.present?
452 scope = scope.where(:scmid => changeset.scmid)
452 scope = scope.where(:scmid => changeset.scmid)
453 else
453 else
454 scope = scope.where(:revision => changeset.revision)
454 scope = scope.where(:revision => changeset.revision)
455 end
455 end
456 scope
456 scope
457 end
457 end
458
458
459 protected
459 protected
460
460
461 # Validates repository url based against an optional regular expression
461 # Validates repository url based against an optional regular expression
462 # that can be set in the Redmine configuration file.
462 # that can be set in the Redmine configuration file.
463 def validate_repository_path(attribute=:url)
463 def validate_repository_path(attribute=:url)
464 regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
464 regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
465 if changes[attribute] && regexp.present?
465 if changes[attribute] && regexp.present?
466 regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
466 regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
467 unless send(attribute).to_s.match(Regexp.new("\\A#{regexp}\\z"))
467 unless send(attribute).to_s.match(Regexp.new("\\A#{regexp}\\z"))
468 errors.add(attribute, :invalid)
468 errors.add(attribute, :invalid)
469 end
469 end
470 end
470 end
471 end
471 end
472
472
473 def normalize_identifier
473 def normalize_identifier
474 self.identifier = identifier.to_s.strip
474 self.identifier = identifier.to_s.strip
475 end
475 end
476
476
477 def check_default
477 def check_default
478 if !is_default? && set_as_default?
478 if !is_default? && set_as_default?
479 self.is_default = true
479 self.is_default = true
480 end
480 end
481 if is_default? && is_default_changed?
481 if is_default? && is_default_changed?
482 Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
482 Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
483 end
483 end
484 end
484 end
485
485
486 def load_entries_changesets(entries)
486 def load_entries_changesets(entries)
487 if entries
487 if entries
488 entries.each do |entry|
488 entries.each do |entry|
489 if entry.lastrev && entry.lastrev.identifier
489 if entry.lastrev && entry.lastrev.identifier
490 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
490 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
491 end
491 end
492 end
492 end
493 end
493 end
494 end
494 end
495
495
496 private
496 private
497
497
498 # Deletes repository data
498 # Deletes repository data
499 def clear_changesets
499 def clear_changesets
500 cs = Changeset.table_name
500 cs = Changeset.table_name
501 ch = Change.table_name
501 ch = Change.table_name
502 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
502 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
503 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
503 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
504
504
505 self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
505 self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
506 self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
506 self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
507 self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
507 self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
508 self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
508 self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
509 end
509 end
510 end
510 end
@@ -1,137 +1,137
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Tracker < ActiveRecord::Base
18 class Tracker < ActiveRecord::Base
19
19
20 CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject description priority_id is_private).freeze
20 CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject description priority_id is_private).freeze
21 # Fields that can be disabled
21 # Fields that can be disabled
22 # Other (future) fields should be appended, not inserted!
22 # Other (future) fields should be appended, not inserted!
23 CORE_FIELDS = %w(assigned_to_id category_id fixed_version_id parent_issue_id start_date due_date estimated_hours done_ratio).freeze
23 CORE_FIELDS = %w(assigned_to_id category_id fixed_version_id parent_issue_id start_date due_date estimated_hours done_ratio).freeze
24 CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze
24 CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze
25
25
26 before_destroy :check_integrity
26 before_destroy :check_integrity
27 belongs_to :default_status, :class_name => 'IssueStatus'
27 belongs_to :default_status, :class_name => 'IssueStatus'
28 has_many :issues
28 has_many :issues
29 has_many :workflow_rules, :dependent => :delete_all do
29 has_many :workflow_rules, :dependent => :delete_all do
30 def copy(source_tracker)
30 def copy(source_tracker)
31 WorkflowRule.copy(source_tracker, nil, proxy_association.owner, nil)
31 WorkflowRule.copy(source_tracker, nil, proxy_association.owner, nil)
32 end
32 end
33 end
33 end
34
34
35 has_and_belongs_to_many :projects
35 has_and_belongs_to_many :projects
36 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
36 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
37 acts_as_positioned
37 acts_as_positioned
38
38
39 attr_protected :fields_bits
39 attr_protected :fields_bits
40
40
41 validates_presence_of :default_status
41 validates_presence_of :default_status
42 validates_presence_of :name
42 validates_presence_of :name
43 validates_uniqueness_of :name
43 validates_uniqueness_of :name
44 validates_length_of :name, :maximum => 30
44 validates_length_of :name, :maximum => 30
45
45
46 scope :sorted, lambda { order(:position) }
46 scope :sorted, lambda { order(:position) }
47 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
47 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
48
48
49 # Returns the trackers that are visible by the user.
49 # Returns the trackers that are visible by the user.
50 #
50 #
51 # Examples:
51 # Examples:
52 # project.trackers.visible(user)
52 # project.trackers.visible(user)
53 # => returns the trackers that are visible by the user in project
53 # => returns the trackers that are visible by the user in project
54 #
54 #
55 # Tracker.visible(user)
55 # Tracker.visible(user)
56 # => returns the trackers that are visible by the user in at least on project
56 # => returns the trackers that are visible by the user in at least on project
57 scope :visible, lambda {|*args|
57 scope :visible, lambda {|*args|
58 user = args.shift || User.current
58 user = args.shift || User.current
59 condition = Project.allowed_to_condition(user, :view_issues) do |role, user|
59 condition = Project.allowed_to_condition(user, :view_issues) do |role, user|
60 unless role.permissions_all_trackers?(:view_issues)
60 unless role.permissions_all_trackers?(:view_issues)
61 tracker_ids = role.permissions_tracker_ids(:view_issues)
61 tracker_ids = role.permissions_tracker_ids(:view_issues)
62 if tracker_ids.any?
62 if tracker_ids.any?
63 "#{Tracker.table_name}.id IN (#{tracker_ids.join(',')})"
63 "#{Tracker.table_name}.id IN (#{tracker_ids.join(',')})"
64 else
64 else
65 '1=0'
65 '1=0'
66 end
66 end
67 end
67 end
68 end
68 end
69 joins(:projects).where(condition).uniq
69 joins(:projects).where(condition).distinct
70 }
70 }
71
71
72 def to_s; name end
72 def to_s; name end
73
73
74 def <=>(tracker)
74 def <=>(tracker)
75 position <=> tracker.position
75 position <=> tracker.position
76 end
76 end
77
77
78 # Returns an array of IssueStatus that are used
78 # Returns an array of IssueStatus that are used
79 # in the tracker's workflows
79 # in the tracker's workflows
80 def issue_statuses
80 def issue_statuses
81 @issue_statuses ||= IssueStatus.where(:id => issue_status_ids).to_a.sort
81 @issue_statuses ||= IssueStatus.where(:id => issue_status_ids).to_a.sort
82 end
82 end
83
83
84 def issue_status_ids
84 def issue_status_ids
85 if new_record?
85 if new_record?
86 []
86 []
87 else
87 else
88 @issue_status_ids ||= WorkflowTransition.where(:tracker_id => id).uniq.pluck(:old_status_id, :new_status_id).flatten.uniq
88 @issue_status_ids ||= WorkflowTransition.where(:tracker_id => id).distinct.pluck(:old_status_id, :new_status_id).flatten.uniq
89 end
89 end
90 end
90 end
91
91
92 def disabled_core_fields
92 def disabled_core_fields
93 i = -1
93 i = -1
94 @disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
94 @disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
95 end
95 end
96
96
97 def core_fields
97 def core_fields
98 CORE_FIELDS - disabled_core_fields
98 CORE_FIELDS - disabled_core_fields
99 end
99 end
100
100
101 def core_fields=(fields)
101 def core_fields=(fields)
102 raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
102 raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
103
103
104 bits = 0
104 bits = 0
105 CORE_FIELDS.each_with_index do |field, i|
105 CORE_FIELDS.each_with_index do |field, i|
106 unless fields.include?(field)
106 unless fields.include?(field)
107 bits |= 2 ** i
107 bits |= 2 ** i
108 end
108 end
109 end
109 end
110 self.fields_bits = bits
110 self.fields_bits = bits
111 @disabled_core_fields = nil
111 @disabled_core_fields = nil
112 core_fields
112 core_fields
113 end
113 end
114
114
115 # Returns the fields that are disabled for all the given trackers
115 # Returns the fields that are disabled for all the given trackers
116 def self.disabled_core_fields(trackers)
116 def self.disabled_core_fields(trackers)
117 if trackers.present?
117 if trackers.present?
118 trackers.map(&:disabled_core_fields).reduce(:&)
118 trackers.map(&:disabled_core_fields).reduce(:&)
119 else
119 else
120 []
120 []
121 end
121 end
122 end
122 end
123
123
124 # Returns the fields that are enabled for one tracker at least
124 # Returns the fields that are enabled for one tracker at least
125 def self.core_fields(trackers)
125 def self.core_fields(trackers)
126 if trackers.present?
126 if trackers.present?
127 trackers.uniq.map(&:core_fields).reduce(:|)
127 trackers.uniq.map(&:core_fields).reduce(:|)
128 else
128 else
129 CORE_FIELDS.dup
129 CORE_FIELDS.dup
130 end
130 end
131 end
131 end
132
132
133 private
133 private
134 def check_integrity
134 def check_integrity
135 raise Exception.new("Cannot delete tracker") if Issue.where(:tracker_id => self.id).any?
135 raise Exception.new("Cannot delete tracker") if Issue.where(:tracker_id => self.id).any?
136 end
136 end
137 end
137 end
@@ -1,923 +1,923
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/sha1"
18 require "digest/sha1"
19
19
20 class User < Principal
20 class User < Principal
21 include Redmine::SafeAttributes
21 include Redmine::SafeAttributes
22
22
23 # Different ways of displaying/sorting users
23 # Different ways of displaying/sorting users
24 USER_FORMATS = {
24 USER_FORMATS = {
25 :firstname_lastname => {
25 :firstname_lastname => {
26 :string => '#{firstname} #{lastname}',
26 :string => '#{firstname} #{lastname}',
27 :order => %w(firstname lastname id),
27 :order => %w(firstname lastname id),
28 :setting_order => 1
28 :setting_order => 1
29 },
29 },
30 :firstname_lastinitial => {
30 :firstname_lastinitial => {
31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
32 :order => %w(firstname lastname id),
32 :order => %w(firstname lastname id),
33 :setting_order => 2
33 :setting_order => 2
34 },
34 },
35 :firstinitial_lastname => {
35 :firstinitial_lastname => {
36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
37 :order => %w(firstname lastname id),
37 :order => %w(firstname lastname id),
38 :setting_order => 2
38 :setting_order => 2
39 },
39 },
40 :firstname => {
40 :firstname => {
41 :string => '#{firstname}',
41 :string => '#{firstname}',
42 :order => %w(firstname id),
42 :order => %w(firstname id),
43 :setting_order => 3
43 :setting_order => 3
44 },
44 },
45 :lastname_firstname => {
45 :lastname_firstname => {
46 :string => '#{lastname} #{firstname}',
46 :string => '#{lastname} #{firstname}',
47 :order => %w(lastname firstname id),
47 :order => %w(lastname firstname id),
48 :setting_order => 4
48 :setting_order => 4
49 },
49 },
50 :lastnamefirstname => {
50 :lastnamefirstname => {
51 :string => '#{lastname}#{firstname}',
51 :string => '#{lastname}#{firstname}',
52 :order => %w(lastname firstname id),
52 :order => %w(lastname firstname id),
53 :setting_order => 5
53 :setting_order => 5
54 },
54 },
55 :lastname_comma_firstname => {
55 :lastname_comma_firstname => {
56 :string => '#{lastname}, #{firstname}',
56 :string => '#{lastname}, #{firstname}',
57 :order => %w(lastname firstname id),
57 :order => %w(lastname firstname id),
58 :setting_order => 6
58 :setting_order => 6
59 },
59 },
60 :lastname => {
60 :lastname => {
61 :string => '#{lastname}',
61 :string => '#{lastname}',
62 :order => %w(lastname id),
62 :order => %w(lastname id),
63 :setting_order => 7
63 :setting_order => 7
64 },
64 },
65 :username => {
65 :username => {
66 :string => '#{login}',
66 :string => '#{login}',
67 :order => %w(login id),
67 :order => %w(login id),
68 :setting_order => 8
68 :setting_order => 8
69 },
69 },
70 }
70 }
71
71
72 MAIL_NOTIFICATION_OPTIONS = [
72 MAIL_NOTIFICATION_OPTIONS = [
73 ['all', :label_user_mail_option_all],
73 ['all', :label_user_mail_option_all],
74 ['selected', :label_user_mail_option_selected],
74 ['selected', :label_user_mail_option_selected],
75 ['only_my_events', :label_user_mail_option_only_my_events],
75 ['only_my_events', :label_user_mail_option_only_my_events],
76 ['only_assigned', :label_user_mail_option_only_assigned],
76 ['only_assigned', :label_user_mail_option_only_assigned],
77 ['only_owner', :label_user_mail_option_only_owner],
77 ['only_owner', :label_user_mail_option_only_owner],
78 ['none', :label_user_mail_option_none]
78 ['none', :label_user_mail_option_none]
79 ]
79 ]
80
80
81 has_and_belongs_to_many :groups,
81 has_and_belongs_to_many :groups,
82 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
82 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
83 :after_add => Proc.new {|user, group| group.user_added(user)},
83 :after_add => Proc.new {|user, group| group.user_added(user)},
84 :after_remove => Proc.new {|user, group| group.user_removed(user)}
84 :after_remove => Proc.new {|user, group| group.user_removed(user)}
85 has_many :changesets, :dependent => :nullify
85 has_many :changesets, :dependent => :nullify
86 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
86 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
87 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
87 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
88 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
88 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
89 has_one :email_address, lambda {where :is_default => true}, :autosave => true
89 has_one :email_address, lambda {where :is_default => true}, :autosave => true
90 has_many :email_addresses, :dependent => :delete_all
90 has_many :email_addresses, :dependent => :delete_all
91 belongs_to :auth_source
91 belongs_to :auth_source
92
92
93 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
93 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
94 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
94 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
95
95
96 acts_as_customizable
96 acts_as_customizable
97
97
98 attr_accessor :password, :password_confirmation, :generate_password
98 attr_accessor :password, :password_confirmation, :generate_password
99 attr_accessor :last_before_login_on
99 attr_accessor :last_before_login_on
100 attr_accessor :remote_ip
100 attr_accessor :remote_ip
101
101
102 # Prevents unauthorized assignments
102 # Prevents unauthorized assignments
103 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
103 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
104
104
105 LOGIN_LENGTH_LIMIT = 60
105 LOGIN_LENGTH_LIMIT = 60
106 MAIL_LENGTH_LIMIT = 60
106 MAIL_LENGTH_LIMIT = 60
107
107
108 validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
108 validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
109 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
109 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
110 # Login must contain letters, numbers, underscores only
110 # Login must contain letters, numbers, underscores only
111 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
111 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
112 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
112 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
113 validates_length_of :firstname, :lastname, :maximum => 30
113 validates_length_of :firstname, :lastname, :maximum => 30
114 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
114 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
115 validate :validate_password_length
115 validate :validate_password_length
116 validate do
116 validate do
117 if password_confirmation && password != password_confirmation
117 if password_confirmation && password != password_confirmation
118 errors.add(:password, :confirmation)
118 errors.add(:password, :confirmation)
119 end
119 end
120 end
120 end
121
121
122 self.valid_statuses = [STATUS_ACTIVE, STATUS_REGISTERED, STATUS_LOCKED]
122 self.valid_statuses = [STATUS_ACTIVE, STATUS_REGISTERED, STATUS_LOCKED]
123
123
124 before_validation :instantiate_email_address
124 before_validation :instantiate_email_address
125 before_create :set_mail_notification
125 before_create :set_mail_notification
126 before_save :generate_password_if_needed, :update_hashed_password
126 before_save :generate_password_if_needed, :update_hashed_password
127 before_destroy :remove_references_before_destroy
127 before_destroy :remove_references_before_destroy
128 after_save :update_notified_project_ids, :destroy_tokens, :deliver_security_notification
128 after_save :update_notified_project_ids, :destroy_tokens, :deliver_security_notification
129 after_destroy :deliver_security_notification
129 after_destroy :deliver_security_notification
130
130
131 scope :in_group, lambda {|group|
131 scope :in_group, lambda {|group|
132 group_id = group.is_a?(Group) ? group.id : group.to_i
132 group_id = group.is_a?(Group) ? group.id : group.to_i
133 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
133 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
134 }
134 }
135 scope :not_in_group, lambda {|group|
135 scope :not_in_group, lambda {|group|
136 group_id = group.is_a?(Group) ? group.id : group.to_i
136 group_id = group.is_a?(Group) ? group.id : group.to_i
137 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
137 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
138 }
138 }
139 scope :sorted, lambda { order(*User.fields_for_order_statement)}
139 scope :sorted, lambda { order(*User.fields_for_order_statement)}
140 scope :having_mail, lambda {|arg|
140 scope :having_mail, lambda {|arg|
141 addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
141 addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
142 if addresses.any?
142 if addresses.any?
143 joins(:email_addresses).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", addresses).uniq
143 joins(:email_addresses).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", addresses).distinct
144 else
144 else
145 none
145 none
146 end
146 end
147 }
147 }
148
148
149 def set_mail_notification
149 def set_mail_notification
150 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
150 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
151 true
151 true
152 end
152 end
153
153
154 def update_hashed_password
154 def update_hashed_password
155 # update hashed_password if password was set
155 # update hashed_password if password was set
156 if self.password && self.auth_source_id.blank?
156 if self.password && self.auth_source_id.blank?
157 salt_password(password)
157 salt_password(password)
158 end
158 end
159 end
159 end
160
160
161 alias :base_reload :reload
161 alias :base_reload :reload
162 def reload(*args)
162 def reload(*args)
163 @name = nil
163 @name = nil
164 @projects_by_role = nil
164 @projects_by_role = nil
165 @membership_by_project_id = nil
165 @membership_by_project_id = nil
166 @notified_projects_ids = nil
166 @notified_projects_ids = nil
167 @notified_projects_ids_changed = false
167 @notified_projects_ids_changed = false
168 @builtin_role = nil
168 @builtin_role = nil
169 @visible_project_ids = nil
169 @visible_project_ids = nil
170 @managed_roles = nil
170 @managed_roles = nil
171 base_reload(*args)
171 base_reload(*args)
172 end
172 end
173
173
174 def mail
174 def mail
175 email_address.try(:address)
175 email_address.try(:address)
176 end
176 end
177
177
178 def mail=(arg)
178 def mail=(arg)
179 email = email_address || build_email_address
179 email = email_address || build_email_address
180 email.address = arg
180 email.address = arg
181 end
181 end
182
182
183 def mail_changed?
183 def mail_changed?
184 email_address.try(:address_changed?)
184 email_address.try(:address_changed?)
185 end
185 end
186
186
187 def mails
187 def mails
188 email_addresses.pluck(:address)
188 email_addresses.pluck(:address)
189 end
189 end
190
190
191 def self.find_or_initialize_by_identity_url(url)
191 def self.find_or_initialize_by_identity_url(url)
192 user = where(:identity_url => url).first
192 user = where(:identity_url => url).first
193 unless user
193 unless user
194 user = User.new
194 user = User.new
195 user.identity_url = url
195 user.identity_url = url
196 end
196 end
197 user
197 user
198 end
198 end
199
199
200 def identity_url=(url)
200 def identity_url=(url)
201 if url.blank?
201 if url.blank?
202 write_attribute(:identity_url, '')
202 write_attribute(:identity_url, '')
203 else
203 else
204 begin
204 begin
205 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
205 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
206 rescue OpenIdAuthentication::InvalidOpenId
206 rescue OpenIdAuthentication::InvalidOpenId
207 # Invalid url, don't save
207 # Invalid url, don't save
208 end
208 end
209 end
209 end
210 self.read_attribute(:identity_url)
210 self.read_attribute(:identity_url)
211 end
211 end
212
212
213 # Returns the user that matches provided login and password, or nil
213 # Returns the user that matches provided login and password, or nil
214 def self.try_to_login(login, password, active_only=true)
214 def self.try_to_login(login, password, active_only=true)
215 login = login.to_s
215 login = login.to_s
216 password = password.to_s
216 password = password.to_s
217
217
218 # Make sure no one can sign in with an empty login or password
218 # Make sure no one can sign in with an empty login or password
219 return nil if login.empty? || password.empty?
219 return nil if login.empty? || password.empty?
220 user = find_by_login(login)
220 user = find_by_login(login)
221 if user
221 if user
222 # user is already in local database
222 # user is already in local database
223 return nil unless user.check_password?(password)
223 return nil unless user.check_password?(password)
224 return nil if !user.active? && active_only
224 return nil if !user.active? && active_only
225 else
225 else
226 # user is not yet registered, try to authenticate with available sources
226 # user is not yet registered, try to authenticate with available sources
227 attrs = AuthSource.authenticate(login, password)
227 attrs = AuthSource.authenticate(login, password)
228 if attrs
228 if attrs
229 user = new(attrs)
229 user = new(attrs)
230 user.login = login
230 user.login = login
231 user.language = Setting.default_language
231 user.language = Setting.default_language
232 if user.save
232 if user.save
233 user.reload
233 user.reload
234 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
234 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
235 end
235 end
236 end
236 end
237 end
237 end
238 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
238 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
239 user
239 user
240 rescue => text
240 rescue => text
241 raise text
241 raise text
242 end
242 end
243
243
244 # Returns the user who matches the given autologin +key+ or nil
244 # Returns the user who matches the given autologin +key+ or nil
245 def self.try_to_autologin(key)
245 def self.try_to_autologin(key)
246 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
246 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
247 if user
247 if user
248 user.update_column(:last_login_on, Time.now)
248 user.update_column(:last_login_on, Time.now)
249 user
249 user
250 end
250 end
251 end
251 end
252
252
253 def self.name_formatter(formatter = nil)
253 def self.name_formatter(formatter = nil)
254 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
254 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
255 end
255 end
256
256
257 # Returns an array of fields names than can be used to make an order statement for users
257 # Returns an array of fields names than can be used to make an order statement for users
258 # according to how user names are displayed
258 # according to how user names are displayed
259 # Examples:
259 # Examples:
260 #
260 #
261 # User.fields_for_order_statement => ['users.login', 'users.id']
261 # User.fields_for_order_statement => ['users.login', 'users.id']
262 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
262 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
263 def self.fields_for_order_statement(table=nil)
263 def self.fields_for_order_statement(table=nil)
264 table ||= table_name
264 table ||= table_name
265 name_formatter[:order].map {|field| "#{table}.#{field}"}
265 name_formatter[:order].map {|field| "#{table}.#{field}"}
266 end
266 end
267
267
268 # Return user's full name for display
268 # Return user's full name for display
269 def name(formatter = nil)
269 def name(formatter = nil)
270 f = self.class.name_formatter(formatter)
270 f = self.class.name_formatter(formatter)
271 if formatter
271 if formatter
272 eval('"' + f[:string] + '"')
272 eval('"' + f[:string] + '"')
273 else
273 else
274 @name ||= eval('"' + f[:string] + '"')
274 @name ||= eval('"' + f[:string] + '"')
275 end
275 end
276 end
276 end
277
277
278 def active?
278 def active?
279 self.status == STATUS_ACTIVE
279 self.status == STATUS_ACTIVE
280 end
280 end
281
281
282 def registered?
282 def registered?
283 self.status == STATUS_REGISTERED
283 self.status == STATUS_REGISTERED
284 end
284 end
285
285
286 def locked?
286 def locked?
287 self.status == STATUS_LOCKED
287 self.status == STATUS_LOCKED
288 end
288 end
289
289
290 def activate
290 def activate
291 self.status = STATUS_ACTIVE
291 self.status = STATUS_ACTIVE
292 end
292 end
293
293
294 def register
294 def register
295 self.status = STATUS_REGISTERED
295 self.status = STATUS_REGISTERED
296 end
296 end
297
297
298 def lock
298 def lock
299 self.status = STATUS_LOCKED
299 self.status = STATUS_LOCKED
300 end
300 end
301
301
302 def activate!
302 def activate!
303 update_attribute(:status, STATUS_ACTIVE)
303 update_attribute(:status, STATUS_ACTIVE)
304 end
304 end
305
305
306 def register!
306 def register!
307 update_attribute(:status, STATUS_REGISTERED)
307 update_attribute(:status, STATUS_REGISTERED)
308 end
308 end
309
309
310 def lock!
310 def lock!
311 update_attribute(:status, STATUS_LOCKED)
311 update_attribute(:status, STATUS_LOCKED)
312 end
312 end
313
313
314 # Returns true if +clear_password+ is the correct user's password, otherwise false
314 # Returns true if +clear_password+ is the correct user's password, otherwise false
315 def check_password?(clear_password)
315 def check_password?(clear_password)
316 if auth_source_id.present?
316 if auth_source_id.present?
317 auth_source.authenticate(self.login, clear_password)
317 auth_source.authenticate(self.login, clear_password)
318 else
318 else
319 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
319 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
320 end
320 end
321 end
321 end
322
322
323 # Generates a random salt and computes hashed_password for +clear_password+
323 # Generates a random salt and computes hashed_password for +clear_password+
324 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
324 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
325 def salt_password(clear_password)
325 def salt_password(clear_password)
326 self.salt = User.generate_salt
326 self.salt = User.generate_salt
327 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
327 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
328 self.passwd_changed_on = Time.now.change(:usec => 0)
328 self.passwd_changed_on = Time.now.change(:usec => 0)
329 end
329 end
330
330
331 # Does the backend storage allow this user to change their password?
331 # Does the backend storage allow this user to change their password?
332 def change_password_allowed?
332 def change_password_allowed?
333 return true if auth_source.nil?
333 return true if auth_source.nil?
334 return auth_source.allow_password_changes?
334 return auth_source.allow_password_changes?
335 end
335 end
336
336
337 # Returns true if the user password has expired
337 # Returns true if the user password has expired
338 def password_expired?
338 def password_expired?
339 period = Setting.password_max_age.to_i
339 period = Setting.password_max_age.to_i
340 if period.zero?
340 if period.zero?
341 false
341 false
342 else
342 else
343 changed_on = self.passwd_changed_on || Time.at(0)
343 changed_on = self.passwd_changed_on || Time.at(0)
344 changed_on < period.days.ago
344 changed_on < period.days.ago
345 end
345 end
346 end
346 end
347
347
348 def must_change_password?
348 def must_change_password?
349 (must_change_passwd? || password_expired?) && change_password_allowed?
349 (must_change_passwd? || password_expired?) && change_password_allowed?
350 end
350 end
351
351
352 def generate_password?
352 def generate_password?
353 generate_password == '1' || generate_password == true
353 generate_password == '1' || generate_password == true
354 end
354 end
355
355
356 # Generate and set a random password on given length
356 # Generate and set a random password on given length
357 def random_password(length=40)
357 def random_password(length=40)
358 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
358 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
359 chars -= %w(0 O 1 l)
359 chars -= %w(0 O 1 l)
360 password = ''
360 password = ''
361 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
361 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
362 self.password = password
362 self.password = password
363 self.password_confirmation = password
363 self.password_confirmation = password
364 self
364 self
365 end
365 end
366
366
367 def pref
367 def pref
368 self.preference ||= UserPreference.new(:user => self)
368 self.preference ||= UserPreference.new(:user => self)
369 end
369 end
370
370
371 def time_zone
371 def time_zone
372 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
372 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
373 end
373 end
374
374
375 def force_default_language?
375 def force_default_language?
376 Setting.force_default_language_for_loggedin?
376 Setting.force_default_language_for_loggedin?
377 end
377 end
378
378
379 def language
379 def language
380 if force_default_language?
380 if force_default_language?
381 Setting.default_language
381 Setting.default_language
382 else
382 else
383 super
383 super
384 end
384 end
385 end
385 end
386
386
387 def wants_comments_in_reverse_order?
387 def wants_comments_in_reverse_order?
388 self.pref[:comments_sorting] == 'desc'
388 self.pref[:comments_sorting] == 'desc'
389 end
389 end
390
390
391 # Return user's RSS key (a 40 chars long string), used to access feeds
391 # Return user's RSS key (a 40 chars long string), used to access feeds
392 def rss_key
392 def rss_key
393 if rss_token.nil?
393 if rss_token.nil?
394 create_rss_token(:action => 'feeds')
394 create_rss_token(:action => 'feeds')
395 end
395 end
396 rss_token.value
396 rss_token.value
397 end
397 end
398
398
399 # Return user's API key (a 40 chars long string), used to access the API
399 # Return user's API key (a 40 chars long string), used to access the API
400 def api_key
400 def api_key
401 if api_token.nil?
401 if api_token.nil?
402 create_api_token(:action => 'api')
402 create_api_token(:action => 'api')
403 end
403 end
404 api_token.value
404 api_token.value
405 end
405 end
406
406
407 # Generates a new session token and returns its value
407 # Generates a new session token and returns its value
408 def generate_session_token
408 def generate_session_token
409 token = Token.create!(:user_id => id, :action => 'session')
409 token = Token.create!(:user_id => id, :action => 'session')
410 token.value
410 token.value
411 end
411 end
412
412
413 # Returns true if token is a valid session token for the user whose id is user_id
413 # Returns true if token is a valid session token for the user whose id is user_id
414 def self.verify_session_token(user_id, token)
414 def self.verify_session_token(user_id, token)
415 return false if user_id.blank? || token.blank?
415 return false if user_id.blank? || token.blank?
416
416
417 scope = Token.where(:user_id => user_id, :value => token.to_s, :action => 'session')
417 scope = Token.where(:user_id => user_id, :value => token.to_s, :action => 'session')
418 if Setting.session_lifetime?
418 if Setting.session_lifetime?
419 scope = scope.where("created_on > ?", Setting.session_lifetime.to_i.minutes.ago)
419 scope = scope.where("created_on > ?", Setting.session_lifetime.to_i.minutes.ago)
420 end
420 end
421 if Setting.session_timeout?
421 if Setting.session_timeout?
422 scope = scope.where("updated_on > ?", Setting.session_timeout.to_i.minutes.ago)
422 scope = scope.where("updated_on > ?", Setting.session_timeout.to_i.minutes.ago)
423 end
423 end
424 scope.update_all(:updated_on => Time.now) == 1
424 scope.update_all(:updated_on => Time.now) == 1
425 end
425 end
426
426
427 # Return an array of project ids for which the user has explicitly turned mail notifications on
427 # Return an array of project ids for which the user has explicitly turned mail notifications on
428 def notified_projects_ids
428 def notified_projects_ids
429 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
429 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
430 end
430 end
431
431
432 def notified_project_ids=(ids)
432 def notified_project_ids=(ids)
433 @notified_projects_ids_changed = true
433 @notified_projects_ids_changed = true
434 @notified_projects_ids = ids.map(&:to_i).uniq.select {|n| n > 0}
434 @notified_projects_ids = ids.map(&:to_i).uniq.select {|n| n > 0}
435 end
435 end
436
436
437 # Updates per project notifications (after_save callback)
437 # Updates per project notifications (after_save callback)
438 def update_notified_project_ids
438 def update_notified_project_ids
439 if @notified_projects_ids_changed
439 if @notified_projects_ids_changed
440 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
440 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
441 members.update_all(:mail_notification => false)
441 members.update_all(:mail_notification => false)
442 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
442 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
443 end
443 end
444 end
444 end
445 private :update_notified_project_ids
445 private :update_notified_project_ids
446
446
447 def valid_notification_options
447 def valid_notification_options
448 self.class.valid_notification_options(self)
448 self.class.valid_notification_options(self)
449 end
449 end
450
450
451 # Only users that belong to more than 1 project can select projects for which they are notified
451 # Only users that belong to more than 1 project can select projects for which they are notified
452 def self.valid_notification_options(user=nil)
452 def self.valid_notification_options(user=nil)
453 # Note that @user.membership.size would fail since AR ignores
453 # Note that @user.membership.size would fail since AR ignores
454 # :include association option when doing a count
454 # :include association option when doing a count
455 if user.nil? || user.memberships.length < 1
455 if user.nil? || user.memberships.length < 1
456 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
456 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
457 else
457 else
458 MAIL_NOTIFICATION_OPTIONS
458 MAIL_NOTIFICATION_OPTIONS
459 end
459 end
460 end
460 end
461
461
462 # Find a user account by matching the exact login and then a case-insensitive
462 # Find a user account by matching the exact login and then a case-insensitive
463 # version. Exact matches will be given priority.
463 # version. Exact matches will be given priority.
464 def self.find_by_login(login)
464 def self.find_by_login(login)
465 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
465 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
466 if login.present?
466 if login.present?
467 # First look for an exact match
467 # First look for an exact match
468 user = where(:login => login).detect {|u| u.login == login}
468 user = where(:login => login).detect {|u| u.login == login}
469 unless user
469 unless user
470 # Fail over to case-insensitive if none was found
470 # Fail over to case-insensitive if none was found
471 user = where("LOWER(login) = ?", login.downcase).first
471 user = where("LOWER(login) = ?", login.downcase).first
472 end
472 end
473 user
473 user
474 end
474 end
475 end
475 end
476
476
477 def self.find_by_rss_key(key)
477 def self.find_by_rss_key(key)
478 Token.find_active_user('feeds', key)
478 Token.find_active_user('feeds', key)
479 end
479 end
480
480
481 def self.find_by_api_key(key)
481 def self.find_by_api_key(key)
482 Token.find_active_user('api', key)
482 Token.find_active_user('api', key)
483 end
483 end
484
484
485 # Makes find_by_mail case-insensitive
485 # Makes find_by_mail case-insensitive
486 def self.find_by_mail(mail)
486 def self.find_by_mail(mail)
487 having_mail(mail).first
487 having_mail(mail).first
488 end
488 end
489
489
490 # Returns true if the default admin account can no longer be used
490 # Returns true if the default admin account can no longer be used
491 def self.default_admin_account_changed?
491 def self.default_admin_account_changed?
492 !User.active.find_by_login("admin").try(:check_password?, "admin")
492 !User.active.find_by_login("admin").try(:check_password?, "admin")
493 end
493 end
494
494
495 def to_s
495 def to_s
496 name
496 name
497 end
497 end
498
498
499 CSS_CLASS_BY_STATUS = {
499 CSS_CLASS_BY_STATUS = {
500 STATUS_ANONYMOUS => 'anon',
500 STATUS_ANONYMOUS => 'anon',
501 STATUS_ACTIVE => 'active',
501 STATUS_ACTIVE => 'active',
502 STATUS_REGISTERED => 'registered',
502 STATUS_REGISTERED => 'registered',
503 STATUS_LOCKED => 'locked'
503 STATUS_LOCKED => 'locked'
504 }
504 }
505
505
506 def css_classes
506 def css_classes
507 "user #{CSS_CLASS_BY_STATUS[status]}"
507 "user #{CSS_CLASS_BY_STATUS[status]}"
508 end
508 end
509
509
510 # Returns the current day according to user's time zone
510 # Returns the current day according to user's time zone
511 def today
511 def today
512 if time_zone.nil?
512 if time_zone.nil?
513 Date.today
513 Date.today
514 else
514 else
515 time_zone.today
515 time_zone.today
516 end
516 end
517 end
517 end
518
518
519 # Returns the day of +time+ according to user's time zone
519 # Returns the day of +time+ according to user's time zone
520 def time_to_date(time)
520 def time_to_date(time)
521 if time_zone.nil?
521 if time_zone.nil?
522 time.to_date
522 time.to_date
523 else
523 else
524 time.in_time_zone(time_zone).to_date
524 time.in_time_zone(time_zone).to_date
525 end
525 end
526 end
526 end
527
527
528 def logged?
528 def logged?
529 true
529 true
530 end
530 end
531
531
532 def anonymous?
532 def anonymous?
533 !logged?
533 !logged?
534 end
534 end
535
535
536 # Returns user's membership for the given project
536 # Returns user's membership for the given project
537 # or nil if the user is not a member of project
537 # or nil if the user is not a member of project
538 def membership(project)
538 def membership(project)
539 project_id = project.is_a?(Project) ? project.id : project
539 project_id = project.is_a?(Project) ? project.id : project
540
540
541 @membership_by_project_id ||= Hash.new {|h, project_id|
541 @membership_by_project_id ||= Hash.new {|h, project_id|
542 h[project_id] = memberships.where(:project_id => project_id).first
542 h[project_id] = memberships.where(:project_id => project_id).first
543 }
543 }
544 @membership_by_project_id[project_id]
544 @membership_by_project_id[project_id]
545 end
545 end
546
546
547 # Returns the user's bult-in role
547 # Returns the user's bult-in role
548 def builtin_role
548 def builtin_role
549 @builtin_role ||= Role.non_member
549 @builtin_role ||= Role.non_member
550 end
550 end
551
551
552 # Return user's roles for project
552 # Return user's roles for project
553 def roles_for_project(project)
553 def roles_for_project(project)
554 # No role on archived projects
554 # No role on archived projects
555 return [] if project.nil? || project.archived?
555 return [] if project.nil? || project.archived?
556 if membership = membership(project)
556 if membership = membership(project)
557 membership.roles.to_a
557 membership.roles.to_a
558 elsif project.is_public?
558 elsif project.is_public?
559 project.override_roles(builtin_role)
559 project.override_roles(builtin_role)
560 else
560 else
561 []
561 []
562 end
562 end
563 end
563 end
564
564
565 # Returns a hash of user's projects grouped by roles
565 # Returns a hash of user's projects grouped by roles
566 def projects_by_role
566 def projects_by_role
567 return @projects_by_role if @projects_by_role
567 return @projects_by_role if @projects_by_role
568
568
569 hash = Hash.new([])
569 hash = Hash.new([])
570
570
571 group_class = anonymous? ? GroupAnonymous : GroupNonMember
571 group_class = anonymous? ? GroupAnonymous : GroupNonMember
572 members = Member.joins(:project, :principal).
572 members = Member.joins(:project, :principal).
573 where("#{Project.table_name}.status <> 9").
573 where("#{Project.table_name}.status <> 9").
574 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
574 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
575 preload(:project, :roles).
575 preload(:project, :roles).
576 to_a
576 to_a
577
577
578 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
578 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
579 members.each do |member|
579 members.each do |member|
580 if member.project
580 if member.project
581 member.roles.each do |role|
581 member.roles.each do |role|
582 hash[role] = [] unless hash.key?(role)
582 hash[role] = [] unless hash.key?(role)
583 hash[role] << member.project
583 hash[role] << member.project
584 end
584 end
585 end
585 end
586 end
586 end
587
587
588 hash.each do |role, projects|
588 hash.each do |role, projects|
589 projects.uniq!
589 projects.uniq!
590 end
590 end
591
591
592 @projects_by_role = hash
592 @projects_by_role = hash
593 end
593 end
594
594
595 # Returns the ids of visible projects
595 # Returns the ids of visible projects
596 def visible_project_ids
596 def visible_project_ids
597 @visible_project_ids ||= Project.visible(self).pluck(:id)
597 @visible_project_ids ||= Project.visible(self).pluck(:id)
598 end
598 end
599
599
600 # Returns the roles that the user is allowed to manage for the given project
600 # Returns the roles that the user is allowed to manage for the given project
601 def managed_roles(project)
601 def managed_roles(project)
602 if admin?
602 if admin?
603 @managed_roles ||= Role.givable.to_a
603 @managed_roles ||= Role.givable.to_a
604 else
604 else
605 membership(project).try(:managed_roles) || []
605 membership(project).try(:managed_roles) || []
606 end
606 end
607 end
607 end
608
608
609 # Returns true if user is arg or belongs to arg
609 # Returns true if user is arg or belongs to arg
610 def is_or_belongs_to?(arg)
610 def is_or_belongs_to?(arg)
611 if arg.is_a?(User)
611 if arg.is_a?(User)
612 self == arg
612 self == arg
613 elsif arg.is_a?(Group)
613 elsif arg.is_a?(Group)
614 arg.users.include?(self)
614 arg.users.include?(self)
615 else
615 else
616 false
616 false
617 end
617 end
618 end
618 end
619
619
620 # Return true if the user is allowed to do the specified action on a specific context
620 # Return true if the user is allowed to do the specified action on a specific context
621 # Action can be:
621 # Action can be:
622 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
622 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
623 # * a permission Symbol (eg. :edit_project)
623 # * a permission Symbol (eg. :edit_project)
624 # Context can be:
624 # Context can be:
625 # * a project : returns true if user is allowed to do the specified action on this project
625 # * a project : returns true if user is allowed to do the specified action on this project
626 # * an array of projects : returns true if user is allowed on every project
626 # * an array of projects : returns true if user is allowed on every project
627 # * nil with options[:global] set : check if user has at least one role allowed for this action,
627 # * nil with options[:global] set : check if user has at least one role allowed for this action,
628 # or falls back to Non Member / Anonymous permissions depending if the user is logged
628 # or falls back to Non Member / Anonymous permissions depending if the user is logged
629 def allowed_to?(action, context, options={}, &block)
629 def allowed_to?(action, context, options={}, &block)
630 if context && context.is_a?(Project)
630 if context && context.is_a?(Project)
631 return false unless context.allows_to?(action)
631 return false unless context.allows_to?(action)
632 # Admin users are authorized for anything else
632 # Admin users are authorized for anything else
633 return true if admin?
633 return true if admin?
634
634
635 roles = roles_for_project(context)
635 roles = roles_for_project(context)
636 return false unless roles
636 return false unless roles
637 roles.any? {|role|
637 roles.any? {|role|
638 (context.is_public? || role.member?) &&
638 (context.is_public? || role.member?) &&
639 role.allowed_to?(action) &&
639 role.allowed_to?(action) &&
640 (block_given? ? yield(role, self) : true)
640 (block_given? ? yield(role, self) : true)
641 }
641 }
642 elsif context && context.is_a?(Array)
642 elsif context && context.is_a?(Array)
643 if context.empty?
643 if context.empty?
644 false
644 false
645 else
645 else
646 # Authorize if user is authorized on every element of the array
646 # Authorize if user is authorized on every element of the array
647 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
647 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
648 end
648 end
649 elsif context
649 elsif context
650 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
650 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
651 elsif options[:global]
651 elsif options[:global]
652 # Admin users are always authorized
652 # Admin users are always authorized
653 return true if admin?
653 return true if admin?
654
654
655 # authorize if user has at least one role that has this permission
655 # authorize if user has at least one role that has this permission
656 roles = memberships.collect {|m| m.roles}.flatten.uniq
656 roles = memberships.collect {|m| m.roles}.flatten.uniq
657 roles << (self.logged? ? Role.non_member : Role.anonymous)
657 roles << (self.logged? ? Role.non_member : Role.anonymous)
658 roles.any? {|role|
658 roles.any? {|role|
659 role.allowed_to?(action) &&
659 role.allowed_to?(action) &&
660 (block_given? ? yield(role, self) : true)
660 (block_given? ? yield(role, self) : true)
661 }
661 }
662 else
662 else
663 false
663 false
664 end
664 end
665 end
665 end
666
666
667 # Is the user allowed to do the specified action on any project?
667 # Is the user allowed to do the specified action on any project?
668 # See allowed_to? for the actions and valid options.
668 # See allowed_to? for the actions and valid options.
669 #
669 #
670 # NB: this method is not used anywhere in the core codebase as of
670 # NB: this method is not used anywhere in the core codebase as of
671 # 2.5.2, but it's used by many plugins so if we ever want to remove
671 # 2.5.2, but it's used by many plugins so if we ever want to remove
672 # it it has to be carefully deprecated for a version or two.
672 # it it has to be carefully deprecated for a version or two.
673 def allowed_to_globally?(action, options={}, &block)
673 def allowed_to_globally?(action, options={}, &block)
674 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
674 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
675 end
675 end
676
676
677 def allowed_to_view_all_time_entries?(context)
677 def allowed_to_view_all_time_entries?(context)
678 allowed_to?(:view_time_entries, context) do |role, user|
678 allowed_to?(:view_time_entries, context) do |role, user|
679 role.time_entries_visibility == 'all'
679 role.time_entries_visibility == 'all'
680 end
680 end
681 end
681 end
682
682
683 # Returns true if the user is allowed to delete the user's own account
683 # Returns true if the user is allowed to delete the user's own account
684 def own_account_deletable?
684 def own_account_deletable?
685 Setting.unsubscribe? &&
685 Setting.unsubscribe? &&
686 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
686 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
687 end
687 end
688
688
689 safe_attributes 'firstname',
689 safe_attributes 'firstname',
690 'lastname',
690 'lastname',
691 'mail',
691 'mail',
692 'mail_notification',
692 'mail_notification',
693 'notified_project_ids',
693 'notified_project_ids',
694 'language',
694 'language',
695 'custom_field_values',
695 'custom_field_values',
696 'custom_fields',
696 'custom_fields',
697 'identity_url'
697 'identity_url'
698
698
699 safe_attributes 'status',
699 safe_attributes 'status',
700 'auth_source_id',
700 'auth_source_id',
701 'generate_password',
701 'generate_password',
702 'must_change_passwd',
702 'must_change_passwd',
703 :if => lambda {|user, current_user| current_user.admin?}
703 :if => lambda {|user, current_user| current_user.admin?}
704
704
705 safe_attributes 'group_ids',
705 safe_attributes 'group_ids',
706 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
706 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
707
707
708 # Utility method to help check if a user should be notified about an
708 # Utility method to help check if a user should be notified about an
709 # event.
709 # event.
710 #
710 #
711 # TODO: only supports Issue events currently
711 # TODO: only supports Issue events currently
712 def notify_about?(object)
712 def notify_about?(object)
713 if mail_notification == 'all'
713 if mail_notification == 'all'
714 true
714 true
715 elsif mail_notification.blank? || mail_notification == 'none'
715 elsif mail_notification.blank? || mail_notification == 'none'
716 false
716 false
717 else
717 else
718 case object
718 case object
719 when Issue
719 when Issue
720 case mail_notification
720 case mail_notification
721 when 'selected', 'only_my_events'
721 when 'selected', 'only_my_events'
722 # user receives notifications for created/assigned issues on unselected projects
722 # user receives notifications for created/assigned issues on unselected projects
723 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
723 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
724 when 'only_assigned'
724 when 'only_assigned'
725 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
725 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
726 when 'only_owner'
726 when 'only_owner'
727 object.author == self
727 object.author == self
728 end
728 end
729 when News
729 when News
730 # always send to project members except when mail_notification is set to 'none'
730 # always send to project members except when mail_notification is set to 'none'
731 true
731 true
732 end
732 end
733 end
733 end
734 end
734 end
735
735
736 def self.current=(user)
736 def self.current=(user)
737 RequestStore.store[:current_user] = user
737 RequestStore.store[:current_user] = user
738 end
738 end
739
739
740 def self.current
740 def self.current
741 RequestStore.store[:current_user] ||= User.anonymous
741 RequestStore.store[:current_user] ||= User.anonymous
742 end
742 end
743
743
744 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
744 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
745 # one anonymous user per database.
745 # one anonymous user per database.
746 def self.anonymous
746 def self.anonymous
747 anonymous_user = AnonymousUser.first
747 anonymous_user = AnonymousUser.first
748 if anonymous_user.nil?
748 if anonymous_user.nil?
749 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
749 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
750 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
750 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
751 end
751 end
752 anonymous_user
752 anonymous_user
753 end
753 end
754
754
755 # Salts all existing unsalted passwords
755 # Salts all existing unsalted passwords
756 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
756 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
757 # This method is used in the SaltPasswords migration and is to be kept as is
757 # This method is used in the SaltPasswords migration and is to be kept as is
758 def self.salt_unsalted_passwords!
758 def self.salt_unsalted_passwords!
759 transaction do
759 transaction do
760 User.where("salt IS NULL OR salt = ''").find_each do |user|
760 User.where("salt IS NULL OR salt = ''").find_each do |user|
761 next if user.hashed_password.blank?
761 next if user.hashed_password.blank?
762 salt = User.generate_salt
762 salt = User.generate_salt
763 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
763 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
764 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
764 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
765 end
765 end
766 end
766 end
767 end
767 end
768
768
769 protected
769 protected
770
770
771 def validate_password_length
771 def validate_password_length
772 return if password.blank? && generate_password?
772 return if password.blank? && generate_password?
773 # Password length validation based on setting
773 # Password length validation based on setting
774 if !password.nil? && password.size < Setting.password_min_length.to_i
774 if !password.nil? && password.size < Setting.password_min_length.to_i
775 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
775 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
776 end
776 end
777 end
777 end
778
778
779 def instantiate_email_address
779 def instantiate_email_address
780 email_address || build_email_address
780 email_address || build_email_address
781 end
781 end
782
782
783 private
783 private
784
784
785 def generate_password_if_needed
785 def generate_password_if_needed
786 if generate_password? && auth_source.nil?
786 if generate_password? && auth_source.nil?
787 length = [Setting.password_min_length.to_i + 2, 10].max
787 length = [Setting.password_min_length.to_i + 2, 10].max
788 random_password(length)
788 random_password(length)
789 end
789 end
790 end
790 end
791
791
792 # Delete all outstanding password reset tokens on password change.
792 # Delete all outstanding password reset tokens on password change.
793 # Delete the autologin tokens on password change to prohibit session leakage.
793 # Delete the autologin tokens on password change to prohibit session leakage.
794 # This helps to keep the account secure in case the associated email account
794 # This helps to keep the account secure in case the associated email account
795 # was compromised.
795 # was compromised.
796 def destroy_tokens
796 def destroy_tokens
797 if hashed_password_changed? || (status_changed? && !active?)
797 if hashed_password_changed? || (status_changed? && !active?)
798 tokens = ['recovery', 'autologin', 'session']
798 tokens = ['recovery', 'autologin', 'session']
799 Token.where(:user_id => id, :action => tokens).delete_all
799 Token.where(:user_id => id, :action => tokens).delete_all
800 end
800 end
801 end
801 end
802
802
803 # Removes references that are not handled by associations
803 # Removes references that are not handled by associations
804 # Things that are not deleted are reassociated with the anonymous user
804 # Things that are not deleted are reassociated with the anonymous user
805 def remove_references_before_destroy
805 def remove_references_before_destroy
806 return if self.id.nil?
806 return if self.id.nil?
807
807
808 substitute = User.anonymous
808 substitute = User.anonymous
809 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
809 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
810 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
810 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
811 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
811 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
812 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
812 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
813 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
813 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
814 JournalDetail.
814 JournalDetail.
815 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
815 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
816 update_all(['old_value = ?', substitute.id.to_s])
816 update_all(['old_value = ?', substitute.id.to_s])
817 JournalDetail.
817 JournalDetail.
818 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
818 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
819 update_all(['value = ?', substitute.id.to_s])
819 update_all(['value = ?', substitute.id.to_s])
820 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
820 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
821 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
821 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
822 # Remove private queries and keep public ones
822 # Remove private queries and keep public ones
823 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
823 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
824 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
824 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
825 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
825 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
826 Token.delete_all ['user_id = ?', id]
826 Token.delete_all ['user_id = ?', id]
827 Watcher.delete_all ['user_id = ?', id]
827 Watcher.delete_all ['user_id = ?', id]
828 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
828 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
829 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
829 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
830 end
830 end
831
831
832 # Return password digest
832 # Return password digest
833 def self.hash_password(clear_password)
833 def self.hash_password(clear_password)
834 Digest::SHA1.hexdigest(clear_password || "")
834 Digest::SHA1.hexdigest(clear_password || "")
835 end
835 end
836
836
837 # Returns a 128bits random salt as a hex string (32 chars long)
837 # Returns a 128bits random salt as a hex string (32 chars long)
838 def self.generate_salt
838 def self.generate_salt
839 Redmine::Utils.random_hex(16)
839 Redmine::Utils.random_hex(16)
840 end
840 end
841
841
842 # Send a security notification to all admins if the user has gained/lost admin privileges
842 # Send a security notification to all admins if the user has gained/lost admin privileges
843 def deliver_security_notification
843 def deliver_security_notification
844 options = {
844 options = {
845 field: :field_admin,
845 field: :field_admin,
846 value: login,
846 value: login,
847 title: :label_user_plural,
847 title: :label_user_plural,
848 url: {controller: 'users', action: 'index'}
848 url: {controller: 'users', action: 'index'}
849 }
849 }
850
850
851 deliver = false
851 deliver = false
852 if (admin? && id_changed? && active?) || # newly created admin
852 if (admin? && id_changed? && active?) || # newly created admin
853 (admin? && admin_changed? && active?) || # regular user became admin
853 (admin? && admin_changed? && active?) || # regular user became admin
854 (admin? && status_changed? && active?) # locked admin became active again
854 (admin? && status_changed? && active?) # locked admin became active again
855
855
856 deliver = true
856 deliver = true
857 options[:message] = :mail_body_security_notification_add
857 options[:message] = :mail_body_security_notification_add
858
858
859 elsif (admin? && destroyed? && active?) || # active admin user was deleted
859 elsif (admin? && destroyed? && active?) || # active admin user was deleted
860 (!admin? && admin_changed? && active?) || # admin is no longer admin
860 (!admin? && admin_changed? && active?) || # admin is no longer admin
861 (admin? && status_changed? && !active?) # admin was locked
861 (admin? && status_changed? && !active?) # admin was locked
862
862
863 deliver = true
863 deliver = true
864 options[:message] = :mail_body_security_notification_remove
864 options[:message] = :mail_body_security_notification_remove
865 end
865 end
866
866
867 if deliver
867 if deliver
868 users = User.active.where(admin: true).to_a
868 users = User.active.where(admin: true).to_a
869 Mailer.security_notification(users, options).deliver
869 Mailer.security_notification(users, options).deliver
870 end
870 end
871 end
871 end
872 end
872 end
873
873
874 class AnonymousUser < User
874 class AnonymousUser < User
875 validate :validate_anonymous_uniqueness, :on => :create
875 validate :validate_anonymous_uniqueness, :on => :create
876
876
877 self.valid_statuses = [STATUS_ANONYMOUS]
877 self.valid_statuses = [STATUS_ANONYMOUS]
878
878
879 def validate_anonymous_uniqueness
879 def validate_anonymous_uniqueness
880 # There should be only one AnonymousUser in the database
880 # There should be only one AnonymousUser in the database
881 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
881 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
882 end
882 end
883
883
884 def available_custom_fields
884 def available_custom_fields
885 []
885 []
886 end
886 end
887
887
888 # Overrides a few properties
888 # Overrides a few properties
889 def logged?; false end
889 def logged?; false end
890 def admin; false end
890 def admin; false end
891 def name(*args); I18n.t(:label_user_anonymous) end
891 def name(*args); I18n.t(:label_user_anonymous) end
892 def mail=(*args); nil end
892 def mail=(*args); nil end
893 def mail; nil end
893 def mail; nil end
894 def time_zone; nil end
894 def time_zone; nil end
895 def rss_key; nil end
895 def rss_key; nil end
896
896
897 def pref
897 def pref
898 UserPreference.new(:user => self)
898 UserPreference.new(:user => self)
899 end
899 end
900
900
901 # Returns the user's bult-in role
901 # Returns the user's bult-in role
902 def builtin_role
902 def builtin_role
903 @builtin_role ||= Role.anonymous
903 @builtin_role ||= Role.anonymous
904 end
904 end
905
905
906 def membership(*args)
906 def membership(*args)
907 nil
907 nil
908 end
908 end
909
909
910 def member_of?(*args)
910 def member_of?(*args)
911 false
911 false
912 end
912 end
913
913
914 # Anonymous user can not be destroyed
914 # Anonymous user can not be destroyed
915 def destroy
915 def destroy
916 false
916 false
917 end
917 end
918
918
919 protected
919 protected
920
920
921 def instantiate_email_address
921 def instantiate_email_address
922 end
922 end
923 end
923 end
@@ -1,217 +1,217
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module Acts
19 module Acts
20 module Searchable
20 module Searchable
21 def self.included(base)
21 def self.included(base)
22 base.extend ClassMethods
22 base.extend ClassMethods
23 end
23 end
24
24
25 module ClassMethods
25 module ClassMethods
26 # Adds the search methods to the class.
26 # Adds the search methods to the class.
27 #
27 #
28 # Options:
28 # Options:
29 # * :columns - a column or an array of columns to search
29 # * :columns - a column or an array of columns to search
30 # * :project_key - project foreign key (default to project_id)
30 # * :project_key - project foreign key (default to project_id)
31 # * :date_column - name of the datetime column used to sort results (default to :created_on)
31 # * :date_column - name of the datetime column used to sort results (default to :created_on)
32 # * :permission - permission required to search the model
32 # * :permission - permission required to search the model
33 # * :scope - scope used to search results
33 # * :scope - scope used to search results
34 # * :preload - associations to preload when loading results for display
34 # * :preload - associations to preload when loading results for display
35 def acts_as_searchable(options = {})
35 def acts_as_searchable(options = {})
36 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
36 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
37 options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
37 options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
38
38
39 cattr_accessor :searchable_options
39 cattr_accessor :searchable_options
40 self.searchable_options = options
40 self.searchable_options = options
41
41
42 if searchable_options[:columns].nil?
42 if searchable_options[:columns].nil?
43 raise 'No searchable column defined.'
43 raise 'No searchable column defined.'
44 elsif !searchable_options[:columns].is_a?(Array)
44 elsif !searchable_options[:columns].is_a?(Array)
45 searchable_options[:columns] = [] << searchable_options[:columns]
45 searchable_options[:columns] = [] << searchable_options[:columns]
46 end
46 end
47
47
48 searchable_options[:project_key] ||= "#{table_name}.project_id"
48 searchable_options[:project_key] ||= "#{table_name}.project_id"
49 searchable_options[:date_column] ||= :created_on
49 searchable_options[:date_column] ||= :created_on
50
50
51 # Should we search additional associations on this model ?
51 # Should we search additional associations on this model ?
52 searchable_options[:search_custom_fields] = reflect_on_association(:custom_values).present?
52 searchable_options[:search_custom_fields] = reflect_on_association(:custom_values).present?
53 searchable_options[:search_attachments] = reflect_on_association(:attachments).present?
53 searchable_options[:search_attachments] = reflect_on_association(:attachments).present?
54 searchable_options[:search_journals] = reflect_on_association(:journals).present?
54 searchable_options[:search_journals] = reflect_on_association(:journals).present?
55
55
56 send :include, Redmine::Acts::Searchable::InstanceMethods
56 send :include, Redmine::Acts::Searchable::InstanceMethods
57 end
57 end
58 end
58 end
59
59
60 module InstanceMethods
60 module InstanceMethods
61 def self.included(base)
61 def self.included(base)
62 base.extend ClassMethods
62 base.extend ClassMethods
63 end
63 end
64
64
65 module ClassMethods
65 module ClassMethods
66 # Searches the model for the given tokens and user visibility.
66 # Searches the model for the given tokens and user visibility.
67 # The projects argument can be either nil (will search all projects), a project or an array of projects.
67 # The projects argument can be either nil (will search all projects), a project or an array of projects.
68 # Returns an array that contains the rank and id of all results.
68 # Returns an array that contains the rank and id of all results.
69 # In current implementation, the rank is the record timestamp converted as an integer.
69 # In current implementation, the rank is the record timestamp converted as an integer.
70 #
70 #
71 # Valid options:
71 # Valid options:
72 # * :titles_only - searches tokens in the first searchable column only
72 # * :titles_only - searches tokens in the first searchable column only
73 # * :all_words - searches results that match all token
73 # * :all_words - searches results that match all token
74 # * :
74 # * :
75 # * :limit - maximum number of results to return
75 # * :limit - maximum number of results to return
76 #
76 #
77 # Example:
77 # Example:
78 # Issue.search_result_ranks_and_ids("foo")
78 # Issue.search_result_ranks_and_ids("foo")
79 # # => [[1419595329, 69], [1419595622, 123]]
79 # # => [[1419595329, 69], [1419595622, 123]]
80 def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
80 def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
81 tokens = [] << tokens unless tokens.is_a?(Array)
81 tokens = [] << tokens unless tokens.is_a?(Array)
82 projects = [] << projects if projects.is_a?(Project)
82 projects = [] << projects if projects.is_a?(Project)
83
83
84 columns = searchable_options[:columns]
84 columns = searchable_options[:columns]
85 columns = columns[0..0] if options[:titles_only]
85 columns = columns[0..0] if options[:titles_only]
86
86
87 r = []
87 r = []
88 queries = 0
88 queries = 0
89
89
90 unless options[:attachments] == 'only'
90 unless options[:attachments] == 'only'
91 r = fetch_ranks_and_ids(
91 r = fetch_ranks_and_ids(
92 search_scope(user, projects, options).
92 search_scope(user, projects, options).
93 where(search_tokens_condition(columns, tokens, options[:all_words])),
93 where(search_tokens_condition(columns, tokens, options[:all_words])),
94 options[:limit]
94 options[:limit]
95 )
95 )
96 queries += 1
96 queries += 1
97
97
98 if !options[:titles_only] && searchable_options[:search_custom_fields]
98 if !options[:titles_only] && searchable_options[:search_custom_fields]
99 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true).to_a
99 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true).to_a
100
100
101 if searchable_custom_fields.any?
101 if searchable_custom_fields.any?
102 fields_by_visibility = searchable_custom_fields.group_by {|field|
102 fields_by_visibility = searchable_custom_fields.group_by {|field|
103 field.visibility_by_project_condition(searchable_options[:project_key], user, "#{CustomValue.table_name}.custom_field_id")
103 field.visibility_by_project_condition(searchable_options[:project_key], user, "#{CustomValue.table_name}.custom_field_id")
104 }
104 }
105 clauses = []
105 clauses = []
106 fields_by_visibility.each do |visibility, fields|
106 fields_by_visibility.each do |visibility, fields|
107 clauses << "(#{CustomValue.table_name}.custom_field_id IN (#{fields.map(&:id).join(',')}) AND (#{visibility}))"
107 clauses << "(#{CustomValue.table_name}.custom_field_id IN (#{fields.map(&:id).join(',')}) AND (#{visibility}))"
108 end
108 end
109 visibility = clauses.join(' OR ')
109 visibility = clauses.join(' OR ')
110
110
111 r |= fetch_ranks_and_ids(
111 r |= fetch_ranks_and_ids(
112 search_scope(user, projects, options).
112 search_scope(user, projects, options).
113 joins(:custom_values).
113 joins(:custom_values).
114 where(visibility).
114 where(visibility).
115 where(search_tokens_condition(["#{CustomValue.table_name}.value"], tokens, options[:all_words])),
115 where(search_tokens_condition(["#{CustomValue.table_name}.value"], tokens, options[:all_words])),
116 options[:limit]
116 options[:limit]
117 )
117 )
118 queries += 1
118 queries += 1
119 end
119 end
120 end
120 end
121
121
122 if !options[:titles_only] && searchable_options[:search_journals]
122 if !options[:titles_only] && searchable_options[:search_journals]
123 r |= fetch_ranks_and_ids(
123 r |= fetch_ranks_and_ids(
124 search_scope(user, projects, options).
124 search_scope(user, projects, options).
125 joins(:journals).
125 joins(:journals).
126 where("#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes)})", false).
126 where("#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes)})", false).
127 where(search_tokens_condition(["#{Journal.table_name}.notes"], tokens, options[:all_words])),
127 where(search_tokens_condition(["#{Journal.table_name}.notes"], tokens, options[:all_words])),
128 options[:limit]
128 options[:limit]
129 )
129 )
130 queries += 1
130 queries += 1
131 end
131 end
132 end
132 end
133
133
134 if searchable_options[:search_attachments] && (options[:titles_only] ? options[:attachments] == 'only' : options[:attachments] != '0')
134 if searchable_options[:search_attachments] && (options[:titles_only] ? options[:attachments] == 'only' : options[:attachments] != '0')
135 r |= fetch_ranks_and_ids(
135 r |= fetch_ranks_and_ids(
136 search_scope(user, projects, options).
136 search_scope(user, projects, options).
137 joins(:attachments).
137 joins(:attachments).
138 where(search_tokens_condition(["#{Attachment.table_name}.filename", "#{Attachment.table_name}.description"], tokens, options[:all_words])),
138 where(search_tokens_condition(["#{Attachment.table_name}.filename", "#{Attachment.table_name}.description"], tokens, options[:all_words])),
139 options[:limit]
139 options[:limit]
140 )
140 )
141 queries += 1
141 queries += 1
142 end
142 end
143
143
144 if queries > 1
144 if queries > 1
145 r = r.sort.reverse
145 r = r.sort.reverse
146 if options[:limit] && r.size > options[:limit]
146 if options[:limit] && r.size > options[:limit]
147 r = r[0, options[:limit]]
147 r = r[0, options[:limit]]
148 end
148 end
149 end
149 end
150
150
151 r
151 r
152 end
152 end
153
153
154 def search_tokens_condition(columns, tokens, all_words)
154 def search_tokens_condition(columns, tokens, all_words)
155 token_clauses = columns.map {|column| "(#{search_token_match_statement(column)})"}
155 token_clauses = columns.map {|column| "(#{search_token_match_statement(column)})"}
156 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(all_words ? ' AND ' : ' OR ')
156 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(all_words ? ' AND ' : ' OR ')
157 [sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
157 [sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
158 end
158 end
159 private :search_tokens_condition
159 private :search_tokens_condition
160
160
161 def search_token_match_statement(column, value='?')
161 def search_token_match_statement(column, value='?')
162 Redmine::Database.like(column, value)
162 Redmine::Database.like(column, value)
163 end
163 end
164 private :search_token_match_statement
164 private :search_token_match_statement
165
165
166 def fetch_ranks_and_ids(scope, limit)
166 def fetch_ranks_and_ids(scope, limit)
167 scope.
167 scope.
168 reorder(searchable_options[:date_column] => :desc, :id => :desc).
168 reorder(searchable_options[:date_column] => :desc, :id => :desc).
169 limit(limit).
169 limit(limit).
170 uniq.
170 distinct.
171 pluck(searchable_options[:date_column], :id).
171 pluck(searchable_options[:date_column], :id).
172 # converts timestamps to integers for faster sort
172 # converts timestamps to integers for faster sort
173 map {|timestamp, id| [timestamp.to_i, id]}
173 map {|timestamp, id| [timestamp.to_i, id]}
174 end
174 end
175 private :fetch_ranks_and_ids
175 private :fetch_ranks_and_ids
176
176
177 # Returns the search scope for user and projects
177 # Returns the search scope for user and projects
178 def search_scope(user, projects, options={})
178 def search_scope(user, projects, options={})
179 if projects.is_a?(Array) && projects.empty?
179 if projects.is_a?(Array) && projects.empty?
180 # no results
180 # no results
181 return none
181 return none
182 end
182 end
183
183
184 scope = (searchable_options[:scope] || self)
184 scope = (searchable_options[:scope] || self)
185 if scope.is_a? Proc
185 if scope.is_a? Proc
186 scope = scope.call(options)
186 scope = scope.call(options)
187 end
187 end
188
188
189 if respond_to?(:visible) && !searchable_options.has_key?(:permission)
189 if respond_to?(:visible) && !searchable_options.has_key?(:permission)
190 scope = scope.visible(user)
190 scope = scope.visible(user)
191 else
191 else
192 permission = searchable_options[:permission] || :view_project
192 permission = searchable_options[:permission] || :view_project
193 scope = scope.where(Project.allowed_to_condition(user, permission))
193 scope = scope.where(Project.allowed_to_condition(user, permission))
194 end
194 end
195
195
196 if projects
196 if projects
197 scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
197 scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
198 end
198 end
199 scope
199 scope
200 end
200 end
201 private :search_scope
201 private :search_scope
202
202
203 # Returns search results of given ids
203 # Returns search results of given ids
204 def search_results_from_ids(ids)
204 def search_results_from_ids(ids)
205 where(:id => ids).preload(searchable_options[:preload]).to_a
205 where(:id => ids).preload(searchable_options[:preload]).to_a
206 end
206 end
207
207
208 # Returns search results with same arguments as search_result_ranks_and_ids
208 # Returns search results with same arguments as search_result_ranks_and_ids
209 def search_results(*args)
209 def search_results(*args)
210 ranks_and_ids = search_result_ranks_and_ids(*args)
210 ranks_and_ids = search_result_ranks_and_ids(*args)
211 search_results_from_ids(ranks_and_ids.map(&:last))
211 search_results_from_ids(ranks_and_ids.map(&:last))
212 end
212 end
213 end
213 end
214 end
214 end
215 end
215 end
216 end
216 end
217 end
217 end
@@ -1,954 +1,954
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module Helpers
19 module Helpers
20 # Simple class to handle gantt chart data
20 # Simple class to handle gantt chart data
21 class Gantt
21 class Gantt
22 class MaxLinesLimitReached < Exception
22 class MaxLinesLimitReached < Exception
23 end
23 end
24
24
25 include ERB::Util
25 include ERB::Util
26 include Redmine::I18n
26 include Redmine::I18n
27 include Redmine::Utils::DateCalculation
27 include Redmine::Utils::DateCalculation
28
28
29 # Relation types that are rendered
29 # Relation types that are rendered
30 DRAW_TYPES = {
30 DRAW_TYPES = {
31 IssueRelation::TYPE_BLOCKS => { :landscape_margin => 16, :color => '#F34F4F' },
31 IssueRelation::TYPE_BLOCKS => { :landscape_margin => 16, :color => '#F34F4F' },
32 IssueRelation::TYPE_PRECEDES => { :landscape_margin => 20, :color => '#628FEA' }
32 IssueRelation::TYPE_PRECEDES => { :landscape_margin => 20, :color => '#628FEA' }
33 }.freeze
33 }.freeze
34
34
35 # :nodoc:
35 # :nodoc:
36 # Some utility methods for the PDF export
36 # Some utility methods for the PDF export
37 class PDF
37 class PDF
38 MaxCharactorsForSubject = 45
38 MaxCharactorsForSubject = 45
39 TotalWidth = 280
39 TotalWidth = 280
40 LeftPaneWidth = 100
40 LeftPaneWidth = 100
41
41
42 def self.right_pane_width
42 def self.right_pane_width
43 TotalWidth - LeftPaneWidth
43 TotalWidth - LeftPaneWidth
44 end
44 end
45 end
45 end
46
46
47 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :truncated, :max_rows
47 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :truncated, :max_rows
48 attr_accessor :query
48 attr_accessor :query
49 attr_accessor :project
49 attr_accessor :project
50 attr_accessor :view
50 attr_accessor :view
51
51
52 def initialize(options={})
52 def initialize(options={})
53 options = options.dup
53 options = options.dup
54 if options[:year] && options[:year].to_i >0
54 if options[:year] && options[:year].to_i >0
55 @year_from = options[:year].to_i
55 @year_from = options[:year].to_i
56 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
56 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
57 @month_from = options[:month].to_i
57 @month_from = options[:month].to_i
58 else
58 else
59 @month_from = 1
59 @month_from = 1
60 end
60 end
61 else
61 else
62 @month_from ||= User.current.today.month
62 @month_from ||= User.current.today.month
63 @year_from ||= User.current.today.year
63 @year_from ||= User.current.today.year
64 end
64 end
65 zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
65 zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
66 @zoom = (zoom > 0 && zoom < 5) ? zoom : 2
66 @zoom = (zoom > 0 && zoom < 5) ? zoom : 2
67 months = (options[:months] || User.current.pref[:gantt_months]).to_i
67 months = (options[:months] || User.current.pref[:gantt_months]).to_i
68 @months = (months > 0 && months < 25) ? months : 6
68 @months = (months > 0 && months < 25) ? months : 6
69 # Save gantt parameters as user preference (zoom and months count)
69 # Save gantt parameters as user preference (zoom and months count)
70 if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] ||
70 if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] ||
71 @months != User.current.pref[:gantt_months]))
71 @months != User.current.pref[:gantt_months]))
72 User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
72 User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
73 User.current.preference.save
73 User.current.preference.save
74 end
74 end
75 @date_from = Date.civil(@year_from, @month_from, 1)
75 @date_from = Date.civil(@year_from, @month_from, 1)
76 @date_to = (@date_from >> @months) - 1
76 @date_to = (@date_from >> @months) - 1
77 @subjects = ''
77 @subjects = ''
78 @lines = ''
78 @lines = ''
79 @number_of_rows = nil
79 @number_of_rows = nil
80 @truncated = false
80 @truncated = false
81 if options.has_key?(:max_rows)
81 if options.has_key?(:max_rows)
82 @max_rows = options[:max_rows]
82 @max_rows = options[:max_rows]
83 else
83 else
84 @max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
84 @max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
85 end
85 end
86 end
86 end
87
87
88 def common_params
88 def common_params
89 { :controller => 'gantts', :action => 'show', :project_id => @project }
89 { :controller => 'gantts', :action => 'show', :project_id => @project }
90 end
90 end
91
91
92 def params
92 def params
93 common_params.merge({:zoom => zoom, :year => year_from,
93 common_params.merge({:zoom => zoom, :year => year_from,
94 :month => month_from, :months => months})
94 :month => month_from, :months => months})
95 end
95 end
96
96
97 def params_previous
97 def params_previous
98 common_params.merge({:year => (date_from << months).year,
98 common_params.merge({:year => (date_from << months).year,
99 :month => (date_from << months).month,
99 :month => (date_from << months).month,
100 :zoom => zoom, :months => months})
100 :zoom => zoom, :months => months})
101 end
101 end
102
102
103 def params_next
103 def params_next
104 common_params.merge({:year => (date_from >> months).year,
104 common_params.merge({:year => (date_from >> months).year,
105 :month => (date_from >> months).month,
105 :month => (date_from >> months).month,
106 :zoom => zoom, :months => months})
106 :zoom => zoom, :months => months})
107 end
107 end
108
108
109 # Returns the number of rows that will be rendered on the Gantt chart
109 # Returns the number of rows that will be rendered on the Gantt chart
110 def number_of_rows
110 def number_of_rows
111 return @number_of_rows if @number_of_rows
111 return @number_of_rows if @number_of_rows
112 rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)}
112 rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)}
113 rows > @max_rows ? @max_rows : rows
113 rows > @max_rows ? @max_rows : rows
114 end
114 end
115
115
116 # Returns the number of rows that will be used to list a project on
116 # Returns the number of rows that will be used to list a project on
117 # the Gantt chart. This will recurse for each subproject.
117 # the Gantt chart. This will recurse for each subproject.
118 def number_of_rows_on_project(project)
118 def number_of_rows_on_project(project)
119 return 0 unless projects.include?(project)
119 return 0 unless projects.include?(project)
120 count = 1
120 count = 1
121 count += project_issues(project).size
121 count += project_issues(project).size
122 count += project_versions(project).size
122 count += project_versions(project).size
123 count
123 count
124 end
124 end
125
125
126 # Renders the subjects of the Gantt chart, the left side.
126 # Renders the subjects of the Gantt chart, the left side.
127 def subjects(options={})
127 def subjects(options={})
128 render(options.merge(:only => :subjects)) unless @subjects_rendered
128 render(options.merge(:only => :subjects)) unless @subjects_rendered
129 @subjects
129 @subjects
130 end
130 end
131
131
132 # Renders the lines of the Gantt chart, the right side
132 # Renders the lines of the Gantt chart, the right side
133 def lines(options={})
133 def lines(options={})
134 render(options.merge(:only => :lines)) unless @lines_rendered
134 render(options.merge(:only => :lines)) unless @lines_rendered
135 @lines
135 @lines
136 end
136 end
137
137
138 # Returns issues that will be rendered
138 # Returns issues that will be rendered
139 def issues
139 def issues
140 @issues ||= @query.issues(
140 @issues ||= @query.issues(
141 :include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
141 :include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
142 :order => "#{Project.table_name}.lft ASC, #{Issue.table_name}.id ASC",
142 :order => "#{Project.table_name}.lft ASC, #{Issue.table_name}.id ASC",
143 :limit => @max_rows
143 :limit => @max_rows
144 )
144 )
145 end
145 end
146
146
147 # Returns a hash of the relations between the issues that are present on the gantt
147 # Returns a hash of the relations between the issues that are present on the gantt
148 # and that should be displayed, grouped by issue ids.
148 # and that should be displayed, grouped by issue ids.
149 def relations
149 def relations
150 return @relations if @relations
150 return @relations if @relations
151 if issues.any?
151 if issues.any?
152 issue_ids = issues.map(&:id)
152 issue_ids = issues.map(&:id)
153 @relations = IssueRelation.
153 @relations = IssueRelation.
154 where(:issue_from_id => issue_ids, :issue_to_id => issue_ids, :relation_type => DRAW_TYPES.keys).
154 where(:issue_from_id => issue_ids, :issue_to_id => issue_ids, :relation_type => DRAW_TYPES.keys).
155 group_by(&:issue_from_id)
155 group_by(&:issue_from_id)
156 else
156 else
157 @relations = {}
157 @relations = {}
158 end
158 end
159 end
159 end
160
160
161 # Return all the project nodes that will be displayed
161 # Return all the project nodes that will be displayed
162 def projects
162 def projects
163 return @projects if @projects
163 return @projects if @projects
164 ids = issues.collect(&:project).uniq.collect(&:id)
164 ids = issues.collect(&:project).uniq.collect(&:id)
165 if ids.any?
165 if ids.any?
166 # All issues projects and their visible ancestors
166 # All issues projects and their visible ancestors
167 @projects = Project.visible.
167 @projects = Project.visible.
168 joins("LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt").
168 joins("LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt").
169 where("child.id IN (?)", ids).
169 where("child.id IN (?)", ids).
170 order("#{Project.table_name}.lft ASC").
170 order("#{Project.table_name}.lft ASC").
171 uniq.
171 distinct.
172 to_a
172 to_a
173 else
173 else
174 @projects = []
174 @projects = []
175 end
175 end
176 end
176 end
177
177
178 # Returns the issues that belong to +project+
178 # Returns the issues that belong to +project+
179 def project_issues(project)
179 def project_issues(project)
180 @issues_by_project ||= issues.group_by(&:project)
180 @issues_by_project ||= issues.group_by(&:project)
181 @issues_by_project[project] || []
181 @issues_by_project[project] || []
182 end
182 end
183
183
184 # Returns the distinct versions of the issues that belong to +project+
184 # Returns the distinct versions of the issues that belong to +project+
185 def project_versions(project)
185 def project_versions(project)
186 project_issues(project).collect(&:fixed_version).compact.uniq
186 project_issues(project).collect(&:fixed_version).compact.uniq
187 end
187 end
188
188
189 # Returns the issues that belong to +project+ and are assigned to +version+
189 # Returns the issues that belong to +project+ and are assigned to +version+
190 def version_issues(project, version)
190 def version_issues(project, version)
191 project_issues(project).select {|issue| issue.fixed_version == version}
191 project_issues(project).select {|issue| issue.fixed_version == version}
192 end
192 end
193
193
194 def render(options={})
194 def render(options={})
195 options = {:top => 0, :top_increment => 20,
195 options = {:top => 0, :top_increment => 20,
196 :indent_increment => 20, :render => :subject,
196 :indent_increment => 20, :render => :subject,
197 :format => :html}.merge(options)
197 :format => :html}.merge(options)
198 indent = options[:indent] || 4
198 indent = options[:indent] || 4
199 @subjects = '' unless options[:only] == :lines
199 @subjects = '' unless options[:only] == :lines
200 @lines = '' unless options[:only] == :subjects
200 @lines = '' unless options[:only] == :subjects
201 @number_of_rows = 0
201 @number_of_rows = 0
202 begin
202 begin
203 Project.project_tree(projects) do |project, level|
203 Project.project_tree(projects) do |project, level|
204 options[:indent] = indent + level * options[:indent_increment]
204 options[:indent] = indent + level * options[:indent_increment]
205 render_project(project, options)
205 render_project(project, options)
206 end
206 end
207 rescue MaxLinesLimitReached
207 rescue MaxLinesLimitReached
208 @truncated = true
208 @truncated = true
209 end
209 end
210 @subjects_rendered = true unless options[:only] == :lines
210 @subjects_rendered = true unless options[:only] == :lines
211 @lines_rendered = true unless options[:only] == :subjects
211 @lines_rendered = true unless options[:only] == :subjects
212 render_end(options)
212 render_end(options)
213 end
213 end
214
214
215 def render_project(project, options={})
215 def render_project(project, options={})
216 render_object_row(project, options)
216 render_object_row(project, options)
217 increment_indent(options) do
217 increment_indent(options) do
218 # render issue that are not assigned to a version
218 # render issue that are not assigned to a version
219 issues = project_issues(project).select {|i| i.fixed_version.nil?}
219 issues = project_issues(project).select {|i| i.fixed_version.nil?}
220 render_issues(issues, options)
220 render_issues(issues, options)
221 # then render project versions and their issues
221 # then render project versions and their issues
222 versions = project_versions(project)
222 versions = project_versions(project)
223 self.class.sort_versions!(versions)
223 self.class.sort_versions!(versions)
224 versions.each do |version|
224 versions.each do |version|
225 render_version(project, version, options)
225 render_version(project, version, options)
226 end
226 end
227 end
227 end
228 end
228 end
229
229
230 def render_version(project, version, options={})
230 def render_version(project, version, options={})
231 render_object_row(version, options)
231 render_object_row(version, options)
232 increment_indent(options) do
232 increment_indent(options) do
233 issues = version_issues(project, version)
233 issues = version_issues(project, version)
234 render_issues(issues, options)
234 render_issues(issues, options)
235 end
235 end
236 end
236 end
237
237
238 def render_issues(issues, options={})
238 def render_issues(issues, options={})
239 self.class.sort_issues!(issues)
239 self.class.sort_issues!(issues)
240 ancestors = []
240 ancestors = []
241 issues.each do |issue|
241 issues.each do |issue|
242 while ancestors.any? && !issue.is_descendant_of?(ancestors.last)
242 while ancestors.any? && !issue.is_descendant_of?(ancestors.last)
243 ancestors.pop
243 ancestors.pop
244 decrement_indent(options)
244 decrement_indent(options)
245 end
245 end
246 render_object_row(issue, options)
246 render_object_row(issue, options)
247 unless issue.leaf?
247 unless issue.leaf?
248 ancestors << issue
248 ancestors << issue
249 increment_indent(options)
249 increment_indent(options)
250 end
250 end
251 end
251 end
252 decrement_indent(options, ancestors.size)
252 decrement_indent(options, ancestors.size)
253 end
253 end
254
254
255 def render_object_row(object, options)
255 def render_object_row(object, options)
256 class_name = object.class.name.downcase
256 class_name = object.class.name.downcase
257 send("subject_for_#{class_name}", object, options) unless options[:only] == :lines
257 send("subject_for_#{class_name}", object, options) unless options[:only] == :lines
258 send("line_for_#{class_name}", object, options) unless options[:only] == :subjects
258 send("line_for_#{class_name}", object, options) unless options[:only] == :subjects
259 options[:top] += options[:top_increment]
259 options[:top] += options[:top_increment]
260 @number_of_rows += 1
260 @number_of_rows += 1
261 if @max_rows && @number_of_rows >= @max_rows
261 if @max_rows && @number_of_rows >= @max_rows
262 raise MaxLinesLimitReached
262 raise MaxLinesLimitReached
263 end
263 end
264 end
264 end
265
265
266 def render_end(options={})
266 def render_end(options={})
267 case options[:format]
267 case options[:format]
268 when :pdf
268 when :pdf
269 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
269 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
270 end
270 end
271 end
271 end
272
272
273 def increment_indent(options, factor=1)
273 def increment_indent(options, factor=1)
274 options[:indent] += options[:indent_increment] * factor
274 options[:indent] += options[:indent_increment] * factor
275 if block_given?
275 if block_given?
276 yield
276 yield
277 decrement_indent(options, factor)
277 decrement_indent(options, factor)
278 end
278 end
279 end
279 end
280
280
281 def decrement_indent(options, factor=1)
281 def decrement_indent(options, factor=1)
282 increment_indent(options, -factor)
282 increment_indent(options, -factor)
283 end
283 end
284
284
285 def subject_for_project(project, options)
285 def subject_for_project(project, options)
286 subject(project.name, options, project)
286 subject(project.name, options, project)
287 end
287 end
288
288
289 def line_for_project(project, options)
289 def line_for_project(project, options)
290 # Skip projects that don't have a start_date or due date
290 # Skip projects that don't have a start_date or due date
291 if project.is_a?(Project) && project.start_date && project.due_date
291 if project.is_a?(Project) && project.start_date && project.due_date
292 label = project.name
292 label = project.name
293 line(project.start_date, project.due_date, nil, true, label, options, project)
293 line(project.start_date, project.due_date, nil, true, label, options, project)
294 end
294 end
295 end
295 end
296
296
297 def subject_for_version(version, options)
297 def subject_for_version(version, options)
298 subject(version.to_s_with_project, options, version)
298 subject(version.to_s_with_project, options, version)
299 end
299 end
300
300
301 def line_for_version(version, options)
301 def line_for_version(version, options)
302 # Skip versions that don't have a start_date
302 # Skip versions that don't have a start_date
303 if version.is_a?(Version) && version.due_date && version.start_date
303 if version.is_a?(Version) && version.due_date && version.start_date
304 label = "#{h(version)} #{h(version.completed_percent.to_f.round)}%"
304 label = "#{h(version)} #{h(version.completed_percent.to_f.round)}%"
305 label = h("#{version.project} -") + label unless @project && @project == version.project
305 label = h("#{version.project} -") + label unless @project && @project == version.project
306 line(version.start_date, version.due_date, version.completed_percent, true, label, options, version)
306 line(version.start_date, version.due_date, version.completed_percent, true, label, options, version)
307 end
307 end
308 end
308 end
309
309
310 def subject_for_issue(issue, options)
310 def subject_for_issue(issue, options)
311 subject(issue.subject, options, issue)
311 subject(issue.subject, options, issue)
312 end
312 end
313
313
314 def line_for_issue(issue, options)
314 def line_for_issue(issue, options)
315 # Skip issues that don't have a due_before (due_date or version's due_date)
315 # Skip issues that don't have a due_before (due_date or version's due_date)
316 if issue.is_a?(Issue) && issue.due_before
316 if issue.is_a?(Issue) && issue.due_before
317 label = "#{issue.status.name} #{issue.done_ratio}%"
317 label = "#{issue.status.name} #{issue.done_ratio}%"
318 markers = !issue.leaf?
318 markers = !issue.leaf?
319 line(issue.start_date, issue.due_before, issue.done_ratio, markers, label, options, issue)
319 line(issue.start_date, issue.due_before, issue.done_ratio, markers, label, options, issue)
320 end
320 end
321 end
321 end
322
322
323 def subject(label, options, object=nil)
323 def subject(label, options, object=nil)
324 send "#{options[:format]}_subject", options, label, object
324 send "#{options[:format]}_subject", options, label, object
325 end
325 end
326
326
327 def line(start_date, end_date, done_ratio, markers, label, options, object=nil)
327 def line(start_date, end_date, done_ratio, markers, label, options, object=nil)
328 options[:zoom] ||= 1
328 options[:zoom] ||= 1
329 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
329 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
330 coords = coordinates(start_date, end_date, done_ratio, options[:zoom])
330 coords = coordinates(start_date, end_date, done_ratio, options[:zoom])
331 send "#{options[:format]}_task", options, coords, markers, label, object
331 send "#{options[:format]}_task", options, coords, markers, label, object
332 end
332 end
333
333
334 # Generates a gantt image
334 # Generates a gantt image
335 # Only defined if RMagick is avalaible
335 # Only defined if RMagick is avalaible
336 def to_image(format='PNG')
336 def to_image(format='PNG')
337 date_to = (@date_from >> @months) - 1
337 date_to = (@date_from >> @months) - 1
338 show_weeks = @zoom > 1
338 show_weeks = @zoom > 1
339 show_days = @zoom > 2
339 show_days = @zoom > 2
340 subject_width = 400
340 subject_width = 400
341 header_height = 18
341 header_height = 18
342 # width of one day in pixels
342 # width of one day in pixels
343 zoom = @zoom * 2
343 zoom = @zoom * 2
344 g_width = (@date_to - @date_from + 1) * zoom
344 g_width = (@date_to - @date_from + 1) * zoom
345 g_height = 20 * number_of_rows + 30
345 g_height = 20 * number_of_rows + 30
346 headers_height = (show_weeks ? 2 * header_height : header_height)
346 headers_height = (show_weeks ? 2 * header_height : header_height)
347 height = g_height + headers_height
347 height = g_height + headers_height
348 imgl = Magick::ImageList.new
348 imgl = Magick::ImageList.new
349 imgl.new_image(subject_width + g_width + 1, height)
349 imgl.new_image(subject_width + g_width + 1, height)
350 gc = Magick::Draw.new
350 gc = Magick::Draw.new
351 gc.font = Redmine::Configuration['rmagick_font_path'] || ""
351 gc.font = Redmine::Configuration['rmagick_font_path'] || ""
352 # Subjects
352 # Subjects
353 gc.stroke('transparent')
353 gc.stroke('transparent')
354 subjects(:image => gc, :top => (headers_height + 20), :indent => 4, :format => :image)
354 subjects(:image => gc, :top => (headers_height + 20), :indent => 4, :format => :image)
355 # Months headers
355 # Months headers
356 month_f = @date_from
356 month_f = @date_from
357 left = subject_width
357 left = subject_width
358 @months.times do
358 @months.times do
359 width = ((month_f >> 1) - month_f) * zoom
359 width = ((month_f >> 1) - month_f) * zoom
360 gc.fill('white')
360 gc.fill('white')
361 gc.stroke('grey')
361 gc.stroke('grey')
362 gc.stroke_width(1)
362 gc.stroke_width(1)
363 gc.rectangle(left, 0, left + width, height)
363 gc.rectangle(left, 0, left + width, height)
364 gc.fill('black')
364 gc.fill('black')
365 gc.stroke('transparent')
365 gc.stroke('transparent')
366 gc.stroke_width(1)
366 gc.stroke_width(1)
367 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
367 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
368 left = left + width
368 left = left + width
369 month_f = month_f >> 1
369 month_f = month_f >> 1
370 end
370 end
371 # Weeks headers
371 # Weeks headers
372 if show_weeks
372 if show_weeks
373 left = subject_width
373 left = subject_width
374 height = header_height
374 height = header_height
375 if @date_from.cwday == 1
375 if @date_from.cwday == 1
376 # date_from is monday
376 # date_from is monday
377 week_f = date_from
377 week_f = date_from
378 else
378 else
379 # find next monday after date_from
379 # find next monday after date_from
380 week_f = @date_from + (7 - @date_from.cwday + 1)
380 week_f = @date_from + (7 - @date_from.cwday + 1)
381 width = (7 - @date_from.cwday + 1) * zoom
381 width = (7 - @date_from.cwday + 1) * zoom
382 gc.fill('white')
382 gc.fill('white')
383 gc.stroke('grey')
383 gc.stroke('grey')
384 gc.stroke_width(1)
384 gc.stroke_width(1)
385 gc.rectangle(left, header_height, left + width, 2 * header_height + g_height - 1)
385 gc.rectangle(left, header_height, left + width, 2 * header_height + g_height - 1)
386 left = left + width
386 left = left + width
387 end
387 end
388 while week_f <= date_to
388 while week_f <= date_to
389 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
389 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
390 gc.fill('white')
390 gc.fill('white')
391 gc.stroke('grey')
391 gc.stroke('grey')
392 gc.stroke_width(1)
392 gc.stroke_width(1)
393 gc.rectangle(left.round, header_height, left.round + width, 2 * header_height + g_height - 1)
393 gc.rectangle(left.round, header_height, left.round + width, 2 * header_height + g_height - 1)
394 gc.fill('black')
394 gc.fill('black')
395 gc.stroke('transparent')
395 gc.stroke('transparent')
396 gc.stroke_width(1)
396 gc.stroke_width(1)
397 gc.text(left.round + 2, header_height + 14, week_f.cweek.to_s)
397 gc.text(left.round + 2, header_height + 14, week_f.cweek.to_s)
398 left = left + width
398 left = left + width
399 week_f = week_f + 7
399 week_f = week_f + 7
400 end
400 end
401 end
401 end
402 # Days details (week-end in grey)
402 # Days details (week-end in grey)
403 if show_days
403 if show_days
404 left = subject_width
404 left = subject_width
405 height = g_height + header_height - 1
405 height = g_height + header_height - 1
406 wday = @date_from.cwday
406 wday = @date_from.cwday
407 (date_to - @date_from + 1).to_i.times do
407 (date_to - @date_from + 1).to_i.times do
408 width = zoom
408 width = zoom
409 gc.fill(non_working_week_days.include?(wday) ? '#eee' : 'white')
409 gc.fill(non_working_week_days.include?(wday) ? '#eee' : 'white')
410 gc.stroke('#ddd')
410 gc.stroke('#ddd')
411 gc.stroke_width(1)
411 gc.stroke_width(1)
412 gc.rectangle(left, 2 * header_height, left + width, 2 * header_height + g_height - 1)
412 gc.rectangle(left, 2 * header_height, left + width, 2 * header_height + g_height - 1)
413 left = left + width
413 left = left + width
414 wday = wday + 1
414 wday = wday + 1
415 wday = 1 if wday > 7
415 wday = 1 if wday > 7
416 end
416 end
417 end
417 end
418 # border
418 # border
419 gc.fill('transparent')
419 gc.fill('transparent')
420 gc.stroke('grey')
420 gc.stroke('grey')
421 gc.stroke_width(1)
421 gc.stroke_width(1)
422 gc.rectangle(0, 0, subject_width + g_width, headers_height)
422 gc.rectangle(0, 0, subject_width + g_width, headers_height)
423 gc.stroke('black')
423 gc.stroke('black')
424 gc.rectangle(0, 0, subject_width + g_width, g_height + headers_height - 1)
424 gc.rectangle(0, 0, subject_width + g_width, g_height + headers_height - 1)
425 # content
425 # content
426 top = headers_height + 20
426 top = headers_height + 20
427 gc.stroke('transparent')
427 gc.stroke('transparent')
428 lines(:image => gc, :top => top, :zoom => zoom,
428 lines(:image => gc, :top => top, :zoom => zoom,
429 :subject_width => subject_width, :format => :image)
429 :subject_width => subject_width, :format => :image)
430 # today red line
430 # today red line
431 if User.current.today >= @date_from and User.current.today <= date_to
431 if User.current.today >= @date_from and User.current.today <= date_to
432 gc.stroke('red')
432 gc.stroke('red')
433 x = (User.current.today - @date_from + 1) * zoom + subject_width
433 x = (User.current.today - @date_from + 1) * zoom + subject_width
434 gc.line(x, headers_height, x, headers_height + g_height - 1)
434 gc.line(x, headers_height, x, headers_height + g_height - 1)
435 end
435 end
436 gc.draw(imgl)
436 gc.draw(imgl)
437 imgl.format = format
437 imgl.format = format
438 imgl.to_blob
438 imgl.to_blob
439 end if Object.const_defined?(:Magick)
439 end if Object.const_defined?(:Magick)
440
440
441 def to_pdf
441 def to_pdf
442 pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
442 pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
443 pdf.SetTitle("#{l(:label_gantt)} #{project}")
443 pdf.SetTitle("#{l(:label_gantt)} #{project}")
444 pdf.alias_nb_pages
444 pdf.alias_nb_pages
445 pdf.footer_date = format_date(User.current.today)
445 pdf.footer_date = format_date(User.current.today)
446 pdf.AddPage("L")
446 pdf.AddPage("L")
447 pdf.SetFontStyle('B', 12)
447 pdf.SetFontStyle('B', 12)
448 pdf.SetX(15)
448 pdf.SetX(15)
449 pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s)
449 pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s)
450 pdf.Ln
450 pdf.Ln
451 pdf.SetFontStyle('B', 9)
451 pdf.SetFontStyle('B', 9)
452 subject_width = PDF::LeftPaneWidth
452 subject_width = PDF::LeftPaneWidth
453 header_height = 5
453 header_height = 5
454 headers_height = header_height
454 headers_height = header_height
455 show_weeks = false
455 show_weeks = false
456 show_days = false
456 show_days = false
457 if self.months < 7
457 if self.months < 7
458 show_weeks = true
458 show_weeks = true
459 headers_height = 2 * header_height
459 headers_height = 2 * header_height
460 if self.months < 3
460 if self.months < 3
461 show_days = true
461 show_days = true
462 headers_height = 3 * header_height
462 headers_height = 3 * header_height
463 if self.months < 2
463 if self.months < 2
464 show_day_num = true
464 show_day_num = true
465 headers_height = 4 * header_height
465 headers_height = 4 * header_height
466 end
466 end
467 end
467 end
468 end
468 end
469 g_width = PDF.right_pane_width
469 g_width = PDF.right_pane_width
470 zoom = (g_width) / (self.date_to - self.date_from + 1)
470 zoom = (g_width) / (self.date_to - self.date_from + 1)
471 g_height = 120
471 g_height = 120
472 t_height = g_height + headers_height
472 t_height = g_height + headers_height
473 y_start = pdf.GetY
473 y_start = pdf.GetY
474 # Months headers
474 # Months headers
475 month_f = self.date_from
475 month_f = self.date_from
476 left = subject_width
476 left = subject_width
477 height = header_height
477 height = header_height
478 self.months.times do
478 self.months.times do
479 width = ((month_f >> 1) - month_f) * zoom
479 width = ((month_f >> 1) - month_f) * zoom
480 pdf.SetY(y_start)
480 pdf.SetY(y_start)
481 pdf.SetX(left)
481 pdf.SetX(left)
482 pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
482 pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
483 left = left + width
483 left = left + width
484 month_f = month_f >> 1
484 month_f = month_f >> 1
485 end
485 end
486 # Weeks headers
486 # Weeks headers
487 if show_weeks
487 if show_weeks
488 left = subject_width
488 left = subject_width
489 height = header_height
489 height = header_height
490 if self.date_from.cwday == 1
490 if self.date_from.cwday == 1
491 # self.date_from is monday
491 # self.date_from is monday
492 week_f = self.date_from
492 week_f = self.date_from
493 else
493 else
494 # find next monday after self.date_from
494 # find next monday after self.date_from
495 week_f = self.date_from + (7 - self.date_from.cwday + 1)
495 week_f = self.date_from + (7 - self.date_from.cwday + 1)
496 width = (7 - self.date_from.cwday + 1) * zoom-1
496 width = (7 - self.date_from.cwday + 1) * zoom-1
497 pdf.SetY(y_start + header_height)
497 pdf.SetY(y_start + header_height)
498 pdf.SetX(left)
498 pdf.SetX(left)
499 pdf.RDMCell(width + 1, height, "", "LTR")
499 pdf.RDMCell(width + 1, height, "", "LTR")
500 left = left + width + 1
500 left = left + width + 1
501 end
501 end
502 while week_f <= self.date_to
502 while week_f <= self.date_to
503 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
503 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
504 pdf.SetY(y_start + header_height)
504 pdf.SetY(y_start + header_height)
505 pdf.SetX(left)
505 pdf.SetX(left)
506 pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
506 pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
507 left = left + width
507 left = left + width
508 week_f = week_f + 7
508 week_f = week_f + 7
509 end
509 end
510 end
510 end
511 # Day numbers headers
511 # Day numbers headers
512 if show_day_num
512 if show_day_num
513 left = subject_width
513 left = subject_width
514 height = header_height
514 height = header_height
515 day_num = self.date_from
515 day_num = self.date_from
516 wday = self.date_from.cwday
516 wday = self.date_from.cwday
517 pdf.SetFontStyle('B', 7)
517 pdf.SetFontStyle('B', 7)
518 (self.date_to - self.date_from + 1).to_i.times do
518 (self.date_to - self.date_from + 1).to_i.times do
519 width = zoom
519 width = zoom
520 pdf.SetY(y_start + header_height * 2)
520 pdf.SetY(y_start + header_height * 2)
521 pdf.SetX(left)
521 pdf.SetX(left)
522 pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
522 pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
523 pdf.RDMCell(width, height, day_num.day.to_s, "LTR", 0, "C")
523 pdf.RDMCell(width, height, day_num.day.to_s, "LTR", 0, "C")
524 left = left + width
524 left = left + width
525 day_num = day_num + 1
525 day_num = day_num + 1
526 wday = wday + 1
526 wday = wday + 1
527 wday = 1 if wday > 7
527 wday = 1 if wday > 7
528 end
528 end
529 end
529 end
530 # Days headers
530 # Days headers
531 if show_days
531 if show_days
532 left = subject_width
532 left = subject_width
533 height = header_height
533 height = header_height
534 wday = self.date_from.cwday
534 wday = self.date_from.cwday
535 pdf.SetFontStyle('B', 7)
535 pdf.SetFontStyle('B', 7)
536 (self.date_to - self.date_from + 1).to_i.times do
536 (self.date_to - self.date_from + 1).to_i.times do
537 width = zoom
537 width = zoom
538 pdf.SetY(y_start + header_height * (show_day_num ? 3 : 2))
538 pdf.SetY(y_start + header_height * (show_day_num ? 3 : 2))
539 pdf.SetX(left)
539 pdf.SetX(left)
540 pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
540 pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
541 pdf.RDMCell(width, height, day_name(wday).first, "LTR", 0, "C")
541 pdf.RDMCell(width, height, day_name(wday).first, "LTR", 0, "C")
542 left = left + width
542 left = left + width
543 wday = wday + 1
543 wday = wday + 1
544 wday = 1 if wday > 7
544 wday = 1 if wday > 7
545 end
545 end
546 end
546 end
547 pdf.SetY(y_start)
547 pdf.SetY(y_start)
548 pdf.SetX(15)
548 pdf.SetX(15)
549 pdf.SetTextColor(0)
549 pdf.SetTextColor(0)
550 pdf.RDMCell(subject_width + g_width - 15, headers_height, "", 1)
550 pdf.RDMCell(subject_width + g_width - 15, headers_height, "", 1)
551 # Tasks
551 # Tasks
552 top = headers_height + y_start
552 top = headers_height + y_start
553 options = {
553 options = {
554 :top => top,
554 :top => top,
555 :zoom => zoom,
555 :zoom => zoom,
556 :subject_width => subject_width,
556 :subject_width => subject_width,
557 :g_width => g_width,
557 :g_width => g_width,
558 :indent => 0,
558 :indent => 0,
559 :indent_increment => 5,
559 :indent_increment => 5,
560 :top_increment => 5,
560 :top_increment => 5,
561 :format => :pdf,
561 :format => :pdf,
562 :pdf => pdf
562 :pdf => pdf
563 }
563 }
564 render(options)
564 render(options)
565 pdf.Output
565 pdf.Output
566 end
566 end
567
567
568 private
568 private
569
569
570 def coordinates(start_date, end_date, progress, zoom=nil)
570 def coordinates(start_date, end_date, progress, zoom=nil)
571 zoom ||= @zoom
571 zoom ||= @zoom
572 coords = {}
572 coords = {}
573 if start_date && end_date && start_date < self.date_to && end_date > self.date_from
573 if start_date && end_date && start_date < self.date_to && end_date > self.date_from
574 if start_date > self.date_from
574 if start_date > self.date_from
575 coords[:start] = start_date - self.date_from
575 coords[:start] = start_date - self.date_from
576 coords[:bar_start] = start_date - self.date_from
576 coords[:bar_start] = start_date - self.date_from
577 else
577 else
578 coords[:bar_start] = 0
578 coords[:bar_start] = 0
579 end
579 end
580 if end_date < self.date_to
580 if end_date < self.date_to
581 coords[:end] = end_date - self.date_from
581 coords[:end] = end_date - self.date_from
582 coords[:bar_end] = end_date - self.date_from + 1
582 coords[:bar_end] = end_date - self.date_from + 1
583 else
583 else
584 coords[:bar_end] = self.date_to - self.date_from + 1
584 coords[:bar_end] = self.date_to - self.date_from + 1
585 end
585 end
586 if progress
586 if progress
587 progress_date = calc_progress_date(start_date, end_date, progress)
587 progress_date = calc_progress_date(start_date, end_date, progress)
588 if progress_date > self.date_from && progress_date > start_date
588 if progress_date > self.date_from && progress_date > start_date
589 if progress_date < self.date_to
589 if progress_date < self.date_to
590 coords[:bar_progress_end] = progress_date - self.date_from
590 coords[:bar_progress_end] = progress_date - self.date_from
591 else
591 else
592 coords[:bar_progress_end] = self.date_to - self.date_from + 1
592 coords[:bar_progress_end] = self.date_to - self.date_from + 1
593 end
593 end
594 end
594 end
595 if progress_date < User.current.today
595 if progress_date < User.current.today
596 late_date = [User.current.today, end_date].min
596 late_date = [User.current.today, end_date].min
597 if late_date > self.date_from && late_date > start_date
597 if late_date > self.date_from && late_date > start_date
598 if late_date < self.date_to
598 if late_date < self.date_to
599 coords[:bar_late_end] = late_date - self.date_from + 1
599 coords[:bar_late_end] = late_date - self.date_from + 1
600 else
600 else
601 coords[:bar_late_end] = self.date_to - self.date_from + 1
601 coords[:bar_late_end] = self.date_to - self.date_from + 1
602 end
602 end
603 end
603 end
604 end
604 end
605 end
605 end
606 end
606 end
607 # Transforms dates into pixels witdh
607 # Transforms dates into pixels witdh
608 coords.keys.each do |key|
608 coords.keys.each do |key|
609 coords[key] = (coords[key] * zoom).floor
609 coords[key] = (coords[key] * zoom).floor
610 end
610 end
611 coords
611 coords
612 end
612 end
613
613
614 def calc_progress_date(start_date, end_date, progress)
614 def calc_progress_date(start_date, end_date, progress)
615 start_date + (end_date - start_date + 1) * (progress / 100.0)
615 start_date + (end_date - start_date + 1) * (progress / 100.0)
616 end
616 end
617
617
618 def self.sort_issues!(issues)
618 def self.sort_issues!(issues)
619 issues.sort! {|a, b| sort_issue_logic(a) <=> sort_issue_logic(b)}
619 issues.sort! {|a, b| sort_issue_logic(a) <=> sort_issue_logic(b)}
620 end
620 end
621
621
622 def self.sort_issue_logic(issue)
622 def self.sort_issue_logic(issue)
623 julian_date = Date.new()
623 julian_date = Date.new()
624 ancesters_start_date = []
624 ancesters_start_date = []
625 current_issue = issue
625 current_issue = issue
626 begin
626 begin
627 ancesters_start_date.unshift([current_issue.start_date || julian_date, current_issue.id])
627 ancesters_start_date.unshift([current_issue.start_date || julian_date, current_issue.id])
628 current_issue = current_issue.parent
628 current_issue = current_issue.parent
629 end while (current_issue)
629 end while (current_issue)
630 ancesters_start_date
630 ancesters_start_date
631 end
631 end
632
632
633 def self.sort_versions!(versions)
633 def self.sort_versions!(versions)
634 versions.sort!
634 versions.sort!
635 end
635 end
636
636
637 def pdf_new_page?(options)
637 def pdf_new_page?(options)
638 if options[:top] > 180
638 if options[:top] > 180
639 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
639 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
640 options[:pdf].AddPage("L")
640 options[:pdf].AddPage("L")
641 options[:top] = 15
641 options[:top] = 15
642 options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
642 options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
643 end
643 end
644 end
644 end
645
645
646 def html_subject_content(object)
646 def html_subject_content(object)
647 case object
647 case object
648 when Issue
648 when Issue
649 issue = object
649 issue = object
650 css_classes = ''
650 css_classes = ''
651 css_classes << ' issue-overdue' if issue.overdue?
651 css_classes << ' issue-overdue' if issue.overdue?
652 css_classes << ' issue-behind-schedule' if issue.behind_schedule?
652 css_classes << ' issue-behind-schedule' if issue.behind_schedule?
653 css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
653 css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
654 css_classes << ' issue-closed' if issue.closed?
654 css_classes << ' issue-closed' if issue.closed?
655 if issue.start_date && issue.due_before && issue.done_ratio
655 if issue.start_date && issue.due_before && issue.done_ratio
656 progress_date = calc_progress_date(issue.start_date,
656 progress_date = calc_progress_date(issue.start_date,
657 issue.due_before, issue.done_ratio)
657 issue.due_before, issue.done_ratio)
658 css_classes << ' behind-start-date' if progress_date < self.date_from
658 css_classes << ' behind-start-date' if progress_date < self.date_from
659 css_classes << ' over-end-date' if progress_date > self.date_to
659 css_classes << ' over-end-date' if progress_date > self.date_to
660 end
660 end
661 s = "".html_safe
661 s = "".html_safe
662 if issue.assigned_to.present?
662 if issue.assigned_to.present?
663 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
663 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
664 s << view.avatar(issue.assigned_to,
664 s << view.avatar(issue.assigned_to,
665 :class => 'gravatar icon-gravatar',
665 :class => 'gravatar icon-gravatar',
666 :size => 10,
666 :size => 10,
667 :title => assigned_string).to_s.html_safe
667 :title => assigned_string).to_s.html_safe
668 end
668 end
669 s << view.link_to_issue(issue).html_safe
669 s << view.link_to_issue(issue).html_safe
670 view.content_tag(:span, s, :class => css_classes).html_safe
670 view.content_tag(:span, s, :class => css_classes).html_safe
671 when Version
671 when Version
672 version = object
672 version = object
673 html_class = ""
673 html_class = ""
674 html_class << 'icon icon-package '
674 html_class << 'icon icon-package '
675 html_class << (version.behind_schedule? ? 'version-behind-schedule' : '') << " "
675 html_class << (version.behind_schedule? ? 'version-behind-schedule' : '') << " "
676 html_class << (version.overdue? ? 'version-overdue' : '')
676 html_class << (version.overdue? ? 'version-overdue' : '')
677 html_class << ' version-closed' unless version.open?
677 html_class << ' version-closed' unless version.open?
678 if version.start_date && version.due_date && version.completed_percent
678 if version.start_date && version.due_date && version.completed_percent
679 progress_date = calc_progress_date(version.start_date,
679 progress_date = calc_progress_date(version.start_date,
680 version.due_date, version.completed_percent)
680 version.due_date, version.completed_percent)
681 html_class << ' behind-start-date' if progress_date < self.date_from
681 html_class << ' behind-start-date' if progress_date < self.date_from
682 html_class << ' over-end-date' if progress_date > self.date_to
682 html_class << ' over-end-date' if progress_date > self.date_to
683 end
683 end
684 s = view.link_to_version(version).html_safe
684 s = view.link_to_version(version).html_safe
685 view.content_tag(:span, s, :class => html_class).html_safe
685 view.content_tag(:span, s, :class => html_class).html_safe
686 when Project
686 when Project
687 project = object
687 project = object
688 html_class = ""
688 html_class = ""
689 html_class << 'icon icon-projects '
689 html_class << 'icon icon-projects '
690 html_class << (project.overdue? ? 'project-overdue' : '')
690 html_class << (project.overdue? ? 'project-overdue' : '')
691 s = view.link_to_project(project).html_safe
691 s = view.link_to_project(project).html_safe
692 view.content_tag(:span, s, :class => html_class).html_safe
692 view.content_tag(:span, s, :class => html_class).html_safe
693 end
693 end
694 end
694 end
695
695
696 def html_subject(params, subject, object)
696 def html_subject(params, subject, object)
697 style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
697 style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
698 style << "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
698 style << "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
699 content = html_subject_content(object) || subject
699 content = html_subject_content(object) || subject
700 tag_options = {:style => style}
700 tag_options = {:style => style}
701 case object
701 case object
702 when Issue
702 when Issue
703 tag_options[:id] = "issue-#{object.id}"
703 tag_options[:id] = "issue-#{object.id}"
704 tag_options[:class] = "issue-subject"
704 tag_options[:class] = "issue-subject"
705 tag_options[:title] = object.subject
705 tag_options[:title] = object.subject
706 when Version
706 when Version
707 tag_options[:id] = "version-#{object.id}"
707 tag_options[:id] = "version-#{object.id}"
708 tag_options[:class] = "version-name"
708 tag_options[:class] = "version-name"
709 when Project
709 when Project
710 tag_options[:class] = "project-name"
710 tag_options[:class] = "project-name"
711 end
711 end
712 output = view.content_tag(:div, content, tag_options)
712 output = view.content_tag(:div, content, tag_options)
713 @subjects << output
713 @subjects << output
714 output
714 output
715 end
715 end
716
716
717 def pdf_subject(params, subject, options={})
717 def pdf_subject(params, subject, options={})
718 pdf_new_page?(params)
718 pdf_new_page?(params)
719 params[:pdf].SetY(params[:top])
719 params[:pdf].SetY(params[:top])
720 params[:pdf].SetX(15)
720 params[:pdf].SetX(15)
721 char_limit = PDF::MaxCharactorsForSubject - params[:indent]
721 char_limit = PDF::MaxCharactorsForSubject - params[:indent]
722 params[:pdf].RDMCell(params[:subject_width] - 15, 5,
722 params[:pdf].RDMCell(params[:subject_width] - 15, 5,
723 (" " * params[:indent]) +
723 (" " * params[:indent]) +
724 subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'),
724 subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'),
725 "LR")
725 "LR")
726 params[:pdf].SetY(params[:top])
726 params[:pdf].SetY(params[:top])
727 params[:pdf].SetX(params[:subject_width])
727 params[:pdf].SetX(params[:subject_width])
728 params[:pdf].RDMCell(params[:g_width], 5, "", "LR")
728 params[:pdf].RDMCell(params[:g_width], 5, "", "LR")
729 end
729 end
730
730
731 def image_subject(params, subject, options={})
731 def image_subject(params, subject, options={})
732 params[:image].fill('black')
732 params[:image].fill('black')
733 params[:image].stroke('transparent')
733 params[:image].stroke('transparent')
734 params[:image].stroke_width(1)
734 params[:image].stroke_width(1)
735 params[:image].text(params[:indent], params[:top] + 2, subject)
735 params[:image].text(params[:indent], params[:top] + 2, subject)
736 end
736 end
737
737
738 def issue_relations(issue)
738 def issue_relations(issue)
739 rels = {}
739 rels = {}
740 if relations[issue.id]
740 if relations[issue.id]
741 relations[issue.id].each do |relation|
741 relations[issue.id].each do |relation|
742 (rels[relation.relation_type] ||= []) << relation.issue_to_id
742 (rels[relation.relation_type] ||= []) << relation.issue_to_id
743 end
743 end
744 end
744 end
745 rels
745 rels
746 end
746 end
747
747
748 def html_task(params, coords, markers, label, object)
748 def html_task(params, coords, markers, label, object)
749 output = ''
749 output = ''
750
750
751 css = "task " + case object
751 css = "task " + case object
752 when Project
752 when Project
753 "project"
753 "project"
754 when Version
754 when Version
755 "version"
755 "version"
756 when Issue
756 when Issue
757 object.leaf? ? 'leaf' : 'parent'
757 object.leaf? ? 'leaf' : 'parent'
758 else
758 else
759 ""
759 ""
760 end
760 end
761
761
762 # Renders the task bar, with progress and late
762 # Renders the task bar, with progress and late
763 if coords[:bar_start] && coords[:bar_end]
763 if coords[:bar_start] && coords[:bar_end]
764 width = coords[:bar_end] - coords[:bar_start] - 2
764 width = coords[:bar_end] - coords[:bar_start] - 2
765 style = ""
765 style = ""
766 style << "top:#{params[:top]}px;"
766 style << "top:#{params[:top]}px;"
767 style << "left:#{coords[:bar_start]}px;"
767 style << "left:#{coords[:bar_start]}px;"
768 style << "width:#{width}px;"
768 style << "width:#{width}px;"
769 html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
769 html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
770 html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
770 html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
771 content_opt = {:style => style,
771 content_opt = {:style => style,
772 :class => "#{css} task_todo",
772 :class => "#{css} task_todo",
773 :id => html_id}
773 :id => html_id}
774 if object.is_a?(Issue)
774 if object.is_a?(Issue)
775 rels = issue_relations(object)
775 rels = issue_relations(object)
776 if rels.present?
776 if rels.present?
777 content_opt[:data] = {"rels" => rels.to_json}
777 content_opt[:data] = {"rels" => rels.to_json}
778 end
778 end
779 end
779 end
780 output << view.content_tag(:div, '&nbsp;'.html_safe, content_opt)
780 output << view.content_tag(:div, '&nbsp;'.html_safe, content_opt)
781 if coords[:bar_late_end]
781 if coords[:bar_late_end]
782 width = coords[:bar_late_end] - coords[:bar_start] - 2
782 width = coords[:bar_late_end] - coords[:bar_start] - 2
783 style = ""
783 style = ""
784 style << "top:#{params[:top]}px;"
784 style << "top:#{params[:top]}px;"
785 style << "left:#{coords[:bar_start]}px;"
785 style << "left:#{coords[:bar_start]}px;"
786 style << "width:#{width}px;"
786 style << "width:#{width}px;"
787 output << view.content_tag(:div, '&nbsp;'.html_safe,
787 output << view.content_tag(:div, '&nbsp;'.html_safe,
788 :style => style,
788 :style => style,
789 :class => "#{css} task_late")
789 :class => "#{css} task_late")
790 end
790 end
791 if coords[:bar_progress_end]
791 if coords[:bar_progress_end]
792 width = coords[:bar_progress_end] - coords[:bar_start] - 2
792 width = coords[:bar_progress_end] - coords[:bar_start] - 2
793 style = ""
793 style = ""
794 style << "top:#{params[:top]}px;"
794 style << "top:#{params[:top]}px;"
795 style << "left:#{coords[:bar_start]}px;"
795 style << "left:#{coords[:bar_start]}px;"
796 style << "width:#{width}px;"
796 style << "width:#{width}px;"
797 html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
797 html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
798 html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
798 html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
799 output << view.content_tag(:div, '&nbsp;'.html_safe,
799 output << view.content_tag(:div, '&nbsp;'.html_safe,
800 :style => style,
800 :style => style,
801 :class => "#{css} task_done",
801 :class => "#{css} task_done",
802 :id => html_id)
802 :id => html_id)
803 end
803 end
804 end
804 end
805 # Renders the markers
805 # Renders the markers
806 if markers
806 if markers
807 if coords[:start]
807 if coords[:start]
808 style = ""
808 style = ""
809 style << "top:#{params[:top]}px;"
809 style << "top:#{params[:top]}px;"
810 style << "left:#{coords[:start]}px;"
810 style << "left:#{coords[:start]}px;"
811 style << "width:15px;"
811 style << "width:15px;"
812 output << view.content_tag(:div, '&nbsp;'.html_safe,
812 output << view.content_tag(:div, '&nbsp;'.html_safe,
813 :style => style,
813 :style => style,
814 :class => "#{css} marker starting")
814 :class => "#{css} marker starting")
815 end
815 end
816 if coords[:end]
816 if coords[:end]
817 style = ""
817 style = ""
818 style << "top:#{params[:top]}px;"
818 style << "top:#{params[:top]}px;"
819 style << "left:#{coords[:end] + params[:zoom]}px;"
819 style << "left:#{coords[:end] + params[:zoom]}px;"
820 style << "width:15px;"
820 style << "width:15px;"
821 output << view.content_tag(:div, '&nbsp;'.html_safe,
821 output << view.content_tag(:div, '&nbsp;'.html_safe,
822 :style => style,
822 :style => style,
823 :class => "#{css} marker ending")
823 :class => "#{css} marker ending")
824 end
824 end
825 end
825 end
826 # Renders the label on the right
826 # Renders the label on the right
827 if label
827 if label
828 style = ""
828 style = ""
829 style << "top:#{params[:top]}px;"
829 style << "top:#{params[:top]}px;"
830 style << "left:#{(coords[:bar_end] || 0) + 8}px;"
830 style << "left:#{(coords[:bar_end] || 0) + 8}px;"
831 style << "width:15px;"
831 style << "width:15px;"
832 output << view.content_tag(:div, label,
832 output << view.content_tag(:div, label,
833 :style => style,
833 :style => style,
834 :class => "#{css} label")
834 :class => "#{css} label")
835 end
835 end
836 # Renders the tooltip
836 # Renders the tooltip
837 if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
837 if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
838 s = view.content_tag(:span,
838 s = view.content_tag(:span,
839 view.render_issue_tooltip(object).html_safe,
839 view.render_issue_tooltip(object).html_safe,
840 :class => "tip")
840 :class => "tip")
841 style = ""
841 style = ""
842 style << "position: absolute;"
842 style << "position: absolute;"
843 style << "top:#{params[:top]}px;"
843 style << "top:#{params[:top]}px;"
844 style << "left:#{coords[:bar_start]}px;"
844 style << "left:#{coords[:bar_start]}px;"
845 style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
845 style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
846 style << "height:12px;"
846 style << "height:12px;"
847 output << view.content_tag(:div, s.html_safe,
847 output << view.content_tag(:div, s.html_safe,
848 :style => style,
848 :style => style,
849 :class => "tooltip")
849 :class => "tooltip")
850 end
850 end
851 @lines << output
851 @lines << output
852 output
852 output
853 end
853 end
854
854
855 def pdf_task(params, coords, markers, label, object)
855 def pdf_task(params, coords, markers, label, object)
856 cell_height_ratio = params[:pdf].get_cell_height_ratio()
856 cell_height_ratio = params[:pdf].get_cell_height_ratio()
857 params[:pdf].set_cell_height_ratio(0.1)
857 params[:pdf].set_cell_height_ratio(0.1)
858
858
859 height = 2
859 height = 2
860 height /= 2 if markers
860 height /= 2 if markers
861 # Renders the task bar, with progress and late
861 # Renders the task bar, with progress and late
862 if coords[:bar_start] && coords[:bar_end]
862 if coords[:bar_start] && coords[:bar_end]
863 params[:pdf].SetY(params[:top] + 1.5)
863 params[:pdf].SetY(params[:top] + 1.5)
864 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
864 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
865 params[:pdf].SetFillColor(200, 200, 200)
865 params[:pdf].SetFillColor(200, 200, 200)
866 params[:pdf].RDMCell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1)
866 params[:pdf].RDMCell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1)
867 if coords[:bar_late_end]
867 if coords[:bar_late_end]
868 params[:pdf].SetY(params[:top] + 1.5)
868 params[:pdf].SetY(params[:top] + 1.5)
869 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
869 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
870 params[:pdf].SetFillColor(255, 100, 100)
870 params[:pdf].SetFillColor(255, 100, 100)
871 params[:pdf].RDMCell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1)
871 params[:pdf].RDMCell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1)
872 end
872 end
873 if coords[:bar_progress_end]
873 if coords[:bar_progress_end]
874 params[:pdf].SetY(params[:top] + 1.5)
874 params[:pdf].SetY(params[:top] + 1.5)
875 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
875 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
876 params[:pdf].SetFillColor(90, 200, 90)
876 params[:pdf].SetFillColor(90, 200, 90)
877 params[:pdf].RDMCell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1)
877 params[:pdf].RDMCell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1)
878 end
878 end
879 end
879 end
880 # Renders the markers
880 # Renders the markers
881 if markers
881 if markers
882 if coords[:start]
882 if coords[:start]
883 params[:pdf].SetY(params[:top] + 1)
883 params[:pdf].SetY(params[:top] + 1)
884 params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
884 params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
885 params[:pdf].SetFillColor(50, 50, 200)
885 params[:pdf].SetFillColor(50, 50, 200)
886 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
886 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
887 end
887 end
888 if coords[:end]
888 if coords[:end]
889 params[:pdf].SetY(params[:top] + 1)
889 params[:pdf].SetY(params[:top] + 1)
890 params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
890 params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
891 params[:pdf].SetFillColor(50, 50, 200)
891 params[:pdf].SetFillColor(50, 50, 200)
892 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
892 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
893 end
893 end
894 end
894 end
895 # Renders the label on the right
895 # Renders the label on the right
896 if label
896 if label
897 params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
897 params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
898 params[:pdf].RDMCell(30, 2, label)
898 params[:pdf].RDMCell(30, 2, label)
899 end
899 end
900
900
901 params[:pdf].set_cell_height_ratio(cell_height_ratio)
901 params[:pdf].set_cell_height_ratio(cell_height_ratio)
902 end
902 end
903
903
904 def image_task(params, coords, markers, label, object)
904 def image_task(params, coords, markers, label, object)
905 height = 6
905 height = 6
906 height /= 2 if markers
906 height /= 2 if markers
907 # Renders the task bar, with progress and late
907 # Renders the task bar, with progress and late
908 if coords[:bar_start] && coords[:bar_end]
908 if coords[:bar_start] && coords[:bar_end]
909 params[:image].fill('#aaa')
909 params[:image].fill('#aaa')
910 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
910 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
911 params[:top],
911 params[:top],
912 params[:subject_width] + coords[:bar_end],
912 params[:subject_width] + coords[:bar_end],
913 params[:top] - height)
913 params[:top] - height)
914 if coords[:bar_late_end]
914 if coords[:bar_late_end]
915 params[:image].fill('#f66')
915 params[:image].fill('#f66')
916 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
916 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
917 params[:top],
917 params[:top],
918 params[:subject_width] + coords[:bar_late_end],
918 params[:subject_width] + coords[:bar_late_end],
919 params[:top] - height)
919 params[:top] - height)
920 end
920 end
921 if coords[:bar_progress_end]
921 if coords[:bar_progress_end]
922 params[:image].fill('#00c600')
922 params[:image].fill('#00c600')
923 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
923 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
924 params[:top],
924 params[:top],
925 params[:subject_width] + coords[:bar_progress_end],
925 params[:subject_width] + coords[:bar_progress_end],
926 params[:top] - height)
926 params[:top] - height)
927 end
927 end
928 end
928 end
929 # Renders the markers
929 # Renders the markers
930 if markers
930 if markers
931 if coords[:start]
931 if coords[:start]
932 x = params[:subject_width] + coords[:start]
932 x = params[:subject_width] + coords[:start]
933 y = params[:top] - height / 2
933 y = params[:top] - height / 2
934 params[:image].fill('blue')
934 params[:image].fill('blue')
935 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
935 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
936 end
936 end
937 if coords[:end]
937 if coords[:end]
938 x = params[:subject_width] + coords[:end] + params[:zoom]
938 x = params[:subject_width] + coords[:end] + params[:zoom]
939 y = params[:top] - height / 2
939 y = params[:top] - height / 2
940 params[:image].fill('blue')
940 params[:image].fill('blue')
941 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
941 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
942 end
942 end
943 end
943 end
944 # Renders the label on the right
944 # Renders the label on the right
945 if label
945 if label
946 params[:image].fill('black')
946 params[:image].fill('black')
947 params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,
947 params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,
948 params[:top] + 1,
948 params[:top] + 1,
949 label)
949 label)
950 end
950 end
951 end
951 end
952 end
952 end
953 end
953 end
954 end
954 end
@@ -1,200 +1,200
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module NestedSet
19 module NestedSet
20 module IssueNestedSet
20 module IssueNestedSet
21 def self.included(base)
21 def self.included(base)
22 base.class_eval do
22 base.class_eval do
23 belongs_to :parent, :class_name => self.name
23 belongs_to :parent, :class_name => self.name
24
24
25 before_create :add_to_nested_set, :if => lambda {|issue| issue.parent.present?}
25 before_create :add_to_nested_set, :if => lambda {|issue| issue.parent.present?}
26 after_create :add_as_root, :if => lambda {|issue| issue.parent.blank?}
26 after_create :add_as_root, :if => lambda {|issue| issue.parent.blank?}
27 before_update :handle_parent_change, :if => lambda {|issue| issue.parent_id_changed?}
27 before_update :handle_parent_change, :if => lambda {|issue| issue.parent_id_changed?}
28 before_destroy :destroy_children
28 before_destroy :destroy_children
29 end
29 end
30 base.extend ClassMethods
30 base.extend ClassMethods
31 base.send :include, Redmine::NestedSet::Traversing
31 base.send :include, Redmine::NestedSet::Traversing
32 end
32 end
33
33
34 private
34 private
35
35
36 def target_lft
36 def target_lft
37 scope_for_max_rgt = self.class.where(:root_id => root_id).where(:parent_id => parent_id)
37 scope_for_max_rgt = self.class.where(:root_id => root_id).where(:parent_id => parent_id)
38 if id
38 if id
39 scope_for_max_rgt = scope_for_max_rgt.where("id < ?", id)
39 scope_for_max_rgt = scope_for_max_rgt.where("id < ?", id)
40 end
40 end
41 max_rgt = scope_for_max_rgt.maximum(:rgt)
41 max_rgt = scope_for_max_rgt.maximum(:rgt)
42 if max_rgt
42 if max_rgt
43 max_rgt + 1
43 max_rgt + 1
44 elsif parent
44 elsif parent
45 parent.lft + 1
45 parent.lft + 1
46 else
46 else
47 1
47 1
48 end
48 end
49 end
49 end
50
50
51 def add_to_nested_set(lock=true)
51 def add_to_nested_set(lock=true)
52 lock_nested_set if lock
52 lock_nested_set if lock
53 parent.send :reload_nested_set_values
53 parent.send :reload_nested_set_values
54 self.root_id = parent.root_id
54 self.root_id = parent.root_id
55 self.lft = target_lft
55 self.lft = target_lft
56 self.rgt = lft + 1
56 self.rgt = lft + 1
57 self.class.where(:root_id => root_id).where("lft >= ? OR rgt >= ?", lft, lft).update_all([
57 self.class.where(:root_id => root_id).where("lft >= ? OR rgt >= ?", lft, lft).update_all([
58 "lft = CASE WHEN lft >= :lft THEN lft + 2 ELSE lft END, " +
58 "lft = CASE WHEN lft >= :lft THEN lft + 2 ELSE lft END, " +
59 "rgt = CASE WHEN rgt >= :lft THEN rgt + 2 ELSE rgt END",
59 "rgt = CASE WHEN rgt >= :lft THEN rgt + 2 ELSE rgt END",
60 {:lft => lft}
60 {:lft => lft}
61 ])
61 ])
62 end
62 end
63
63
64 def add_as_root
64 def add_as_root
65 self.root_id = id
65 self.root_id = id
66 self.lft = 1
66 self.lft = 1
67 self.rgt = 2
67 self.rgt = 2
68 self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
68 self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
69 end
69 end
70
70
71 def handle_parent_change
71 def handle_parent_change
72 lock_nested_set
72 lock_nested_set
73 reload_nested_set_values
73 reload_nested_set_values
74 if parent_id_was
74 if parent_id_was
75 remove_from_nested_set
75 remove_from_nested_set
76 end
76 end
77 if parent
77 if parent
78 move_to_nested_set
78 move_to_nested_set
79 end
79 end
80 reload_nested_set_values
80 reload_nested_set_values
81 end
81 end
82
82
83 def move_to_nested_set
83 def move_to_nested_set
84 if parent
84 if parent
85 previous_root_id = root_id
85 previous_root_id = root_id
86 self.root_id = parent.root_id
86 self.root_id = parent.root_id
87
87
88 lft_after_move = target_lft
88 lft_after_move = target_lft
89 self.class.where(:root_id => parent.root_id).update_all([
89 self.class.where(:root_id => parent.root_id).update_all([
90 "lft = CASE WHEN lft >= :lft THEN lft + :shift ELSE lft END, " +
90 "lft = CASE WHEN lft >= :lft THEN lft + :shift ELSE lft END, " +
91 "rgt = CASE WHEN rgt >= :lft THEN rgt + :shift ELSE rgt END",
91 "rgt = CASE WHEN rgt >= :lft THEN rgt + :shift ELSE rgt END",
92 {:lft => lft_after_move, :shift => (rgt - lft + 1)}
92 {:lft => lft_after_move, :shift => (rgt - lft + 1)}
93 ])
93 ])
94
94
95 self.class.where(:root_id => previous_root_id).update_all([
95 self.class.where(:root_id => previous_root_id).update_all([
96 "root_id = :root_id, lft = lft + :shift, rgt = rgt + :shift",
96 "root_id = :root_id, lft = lft + :shift, rgt = rgt + :shift",
97 {:root_id => parent.root_id, :shift => lft_after_move - lft}
97 {:root_id => parent.root_id, :shift => lft_after_move - lft}
98 ])
98 ])
99
99
100 self.lft, self.rgt = lft_after_move, (rgt - lft + lft_after_move)
100 self.lft, self.rgt = lft_after_move, (rgt - lft + lft_after_move)
101 parent.send :reload_nested_set_values
101 parent.send :reload_nested_set_values
102 end
102 end
103 end
103 end
104
104
105 def remove_from_nested_set
105 def remove_from_nested_set
106 self.class.where(:root_id => root_id).where("lft >= ? AND rgt <= ?", lft, rgt).
106 self.class.where(:root_id => root_id).where("lft >= ? AND rgt <= ?", lft, rgt).
107 update_all(["root_id = :id, lft = lft - :shift, rgt = rgt - :shift", {:id => id, :shift => lft - 1}])
107 update_all(["root_id = :id, lft = lft - :shift, rgt = rgt - :shift", {:id => id, :shift => lft - 1}])
108
108
109 self.class.where(:root_id => root_id).update_all([
109 self.class.where(:root_id => root_id).update_all([
110 "lft = CASE WHEN lft >= :lft THEN lft - :shift ELSE lft END, " +
110 "lft = CASE WHEN lft >= :lft THEN lft - :shift ELSE lft END, " +
111 "rgt = CASE WHEN rgt >= :lft THEN rgt - :shift ELSE rgt END",
111 "rgt = CASE WHEN rgt >= :lft THEN rgt - :shift ELSE rgt END",
112 {:lft => lft, :shift => rgt - lft + 1}
112 {:lft => lft, :shift => rgt - lft + 1}
113 ])
113 ])
114 self.root_id = id
114 self.root_id = id
115 self.lft, self.rgt = 1, (rgt - lft + 1)
115 self.lft, self.rgt = 1, (rgt - lft + 1)
116 end
116 end
117
117
118 def destroy_children
118 def destroy_children
119 unless @without_nested_set_update
119 unless @without_nested_set_update
120 lock_nested_set
120 lock_nested_set
121 reload_nested_set_values
121 reload_nested_set_values
122 end
122 end
123 children.each {|c| c.send :destroy_without_nested_set_update}
123 children.each {|c| c.send :destroy_without_nested_set_update}
124 reload
124 reload
125 unless @without_nested_set_update
125 unless @without_nested_set_update
126 self.class.where(:root_id => root_id).where("lft > ? OR rgt > ?", lft, lft).update_all([
126 self.class.where(:root_id => root_id).where("lft > ? OR rgt > ?", lft, lft).update_all([
127 "lft = CASE WHEN lft > :lft THEN lft - :shift ELSE lft END, " +
127 "lft = CASE WHEN lft > :lft THEN lft - :shift ELSE lft END, " +
128 "rgt = CASE WHEN rgt > :lft THEN rgt - :shift ELSE rgt END",
128 "rgt = CASE WHEN rgt > :lft THEN rgt - :shift ELSE rgt END",
129 {:lft => lft, :shift => rgt - lft + 1}
129 {:lft => lft, :shift => rgt - lft + 1}
130 ])
130 ])
131 end
131 end
132 end
132 end
133
133
134 def destroy_without_nested_set_update
134 def destroy_without_nested_set_update
135 @without_nested_set_update = true
135 @without_nested_set_update = true
136 destroy
136 destroy
137 end
137 end
138
138
139 def reload_nested_set_values
139 def reload_nested_set_values
140 self.root_id, self.lft, self.rgt = self.class.where(:id => id).pluck(:root_id, :lft, :rgt).first
140 self.root_id, self.lft, self.rgt = self.class.where(:id => id).pluck(:root_id, :lft, :rgt).first
141 end
141 end
142
142
143 def save_nested_set_values
143 def save_nested_set_values
144 self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
144 self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
145 end
145 end
146
146
147 def move_possible?(issue)
147 def move_possible?(issue)
148 new_record? || !is_or_is_ancestor_of?(issue)
148 new_record? || !is_or_is_ancestor_of?(issue)
149 end
149 end
150
150
151 def lock_nested_set
151 def lock_nested_set
152 if self.class.connection.adapter_name =~ /sqlserver/i
152 if self.class.connection.adapter_name =~ /sqlserver/i
153 lock = "WITH (ROWLOCK HOLDLOCK UPDLOCK)"
153 lock = "WITH (ROWLOCK HOLDLOCK UPDLOCK)"
154 # Custom lock for SQLServer
154 # Custom lock for SQLServer
155 # This can be problematic if root_id or parent root_id changes
155 # This can be problematic if root_id or parent root_id changes
156 # before locking
156 # before locking
157 sets_to_lock = [root_id, parent.try(:root_id)].compact.uniq
157 sets_to_lock = [root_id, parent.try(:root_id)].compact.uniq
158 self.class.reorder(:id).where(:root_id => sets_to_lock).lock(lock).ids
158 self.class.reorder(:id).where(:root_id => sets_to_lock).lock(lock).ids
159 else
159 else
160 sets_to_lock = [id, parent_id].compact
160 sets_to_lock = [id, parent_id].compact
161 self.class.reorder(:id).where("root_id IN (SELECT root_id FROM #{self.class.table_name} WHERE id IN (?))", sets_to_lock).lock.ids
161 self.class.reorder(:id).where("root_id IN (SELECT root_id FROM #{self.class.table_name} WHERE id IN (?))", sets_to_lock).lock.ids
162 end
162 end
163 end
163 end
164
164
165 def nested_set_scope
165 def nested_set_scope
166 self.class.order(:lft).where(:root_id => root_id)
166 self.class.order(:lft).where(:root_id => root_id)
167 end
167 end
168
168
169 def same_nested_set_scope?(issue)
169 def same_nested_set_scope?(issue)
170 root_id == issue.root_id
170 root_id == issue.root_id
171 end
171 end
172
172
173 module ClassMethods
173 module ClassMethods
174 def rebuild_tree!
174 def rebuild_tree!
175 transaction do
175 transaction do
176 reorder(:id).lock.ids
176 reorder(:id).lock.ids
177 update_all(:root_id => nil, :lft => nil, :rgt => nil)
177 update_all(:root_id => nil, :lft => nil, :rgt => nil)
178 where(:parent_id => nil).update_all(["root_id = id, lft = ?, rgt = ?", 1, 2])
178 where(:parent_id => nil).update_all(["root_id = id, lft = ?, rgt = ?", 1, 2])
179 roots_with_children = joins("JOIN #{table_name} parent ON parent.id = #{table_name}.parent_id AND parent.id = parent.root_id").uniq.pluck("parent.id")
179 roots_with_children = joins("JOIN #{table_name} parent ON parent.id = #{table_name}.parent_id AND parent.id = parent.root_id").distinct.pluck("parent.id")
180 roots_with_children.each do |root_id|
180 roots_with_children.each do |root_id|
181 rebuild_nodes(root_id)
181 rebuild_nodes(root_id)
182 end
182 end
183 end
183 end
184 end
184 end
185
185
186 private
186 private
187
187
188 def rebuild_nodes(parent_id = nil)
188 def rebuild_nodes(parent_id = nil)
189 nodes = where(:parent_id => parent_id, :rgt => nil, :lft => nil).order(:id).to_a
189 nodes = where(:parent_id => parent_id, :rgt => nil, :lft => nil).order(:id).to_a
190
190
191 nodes.each do |node|
191 nodes.each do |node|
192 node.send :add_to_nested_set, false
192 node.send :add_to_nested_set, false
193 node.send :save_nested_set_values
193 node.send :save_nested_set_values
194 rebuild_nodes node.id
194 rebuild_nodes node.id
195 end
195 end
196 end
196 end
197 end
197 end
198 end
198 end
199 end
199 end
200 end
200 end
@@ -1,244 +1,244
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class IssuesTest < Redmine::IntegrationTest
20 class IssuesTest < Redmine::IntegrationTest
21 fixtures :projects,
21 fixtures :projects,
22 :users, :email_addresses,
22 :users, :email_addresses,
23 :roles,
23 :roles,
24 :members,
24 :members,
25 :member_roles,
25 :member_roles,
26 :trackers,
26 :trackers,
27 :projects_trackers,
27 :projects_trackers,
28 :enabled_modules,
28 :enabled_modules,
29 :issue_statuses,
29 :issue_statuses,
30 :issues,
30 :issues,
31 :enumerations,
31 :enumerations,
32 :custom_fields,
32 :custom_fields,
33 :custom_values,
33 :custom_values,
34 :custom_fields_trackers,
34 :custom_fields_trackers,
35 :attachments
35 :attachments
36
36
37 # create an issue
37 # create an issue
38 def test_add_issue
38 def test_add_issue
39 log_user('jsmith', 'jsmith')
39 log_user('jsmith', 'jsmith')
40
40
41 get '/projects/ecookbook/issues/new'
41 get '/projects/ecookbook/issues/new'
42 assert_response :success
42 assert_response :success
43 assert_template 'issues/new'
43 assert_template 'issues/new'
44
44
45 issue = new_record(Issue) do
45 issue = new_record(Issue) do
46 post '/projects/ecookbook/issues',
46 post '/projects/ecookbook/issues',
47 :issue => { :tracker_id => "1",
47 :issue => { :tracker_id => "1",
48 :start_date => "2006-12-26",
48 :start_date => "2006-12-26",
49 :priority_id => "4",
49 :priority_id => "4",
50 :subject => "new test issue",
50 :subject => "new test issue",
51 :category_id => "",
51 :category_id => "",
52 :description => "new issue",
52 :description => "new issue",
53 :done_ratio => "0",
53 :done_ratio => "0",
54 :due_date => "",
54 :due_date => "",
55 :assigned_to_id => "" },
55 :assigned_to_id => "" },
56 :custom_fields => {'2' => 'Value for field 2'}
56 :custom_fields => {'2' => 'Value for field 2'}
57 end
57 end
58 # check redirection
58 # check redirection
59 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
59 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
60 follow_redirect!
60 follow_redirect!
61 assert_equal issue, assigns(:issue)
61 assert_equal issue, assigns(:issue)
62
62
63 # check issue attributes
63 # check issue attributes
64 assert_equal 'jsmith', issue.author.login
64 assert_equal 'jsmith', issue.author.login
65 assert_equal 1, issue.project.id
65 assert_equal 1, issue.project.id
66 assert_equal 1, issue.status.id
66 assert_equal 1, issue.status.id
67 end
67 end
68
68
69 def test_create_issue_by_anonymous_without_permission_should_fail
69 def test_create_issue_by_anonymous_without_permission_should_fail
70 Role.anonymous.remove_permission! :add_issues
70 Role.anonymous.remove_permission! :add_issues
71
71
72 assert_no_difference 'Issue.count' do
72 assert_no_difference 'Issue.count' do
73 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
73 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
74 end
74 end
75 assert_response 302
75 assert_response 302
76 end
76 end
77
77
78 def test_create_issue_by_anonymous_with_custom_permission_should_succeed
78 def test_create_issue_by_anonymous_with_custom_permission_should_succeed
79 Role.anonymous.remove_permission! :add_issues
79 Role.anonymous.remove_permission! :add_issues
80 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [3])
80 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [3])
81
81
82 issue = new_record(Issue) do
82 issue = new_record(Issue) do
83 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
83 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
84 assert_response 302
84 assert_response 302
85 end
85 end
86 assert_equal User.anonymous, issue.author
86 assert_equal User.anonymous, issue.author
87 end
87 end
88
88
89 # add then remove 2 attachments to an issue
89 # add then remove 2 attachments to an issue
90 def test_issue_attachments
90 def test_issue_attachments
91 log_user('jsmith', 'jsmith')
91 log_user('jsmith', 'jsmith')
92 set_tmp_attachments_directory
92 set_tmp_attachments_directory
93
93
94 attachment = new_record(Attachment) do
94 attachment = new_record(Attachment) do
95 put '/issues/1',
95 put '/issues/1',
96 :notes => 'Some notes',
96 :notes => 'Some notes',
97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'This is an attachment'}}
97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'This is an attachment'}}
98 assert_redirected_to "/issues/1"
98 assert_redirected_to "/issues/1"
99 end
99 end
100
100
101 assert_equal Issue.find(1), attachment.container
101 assert_equal Issue.find(1), attachment.container
102 assert_equal 'testfile.txt', attachment.filename
102 assert_equal 'testfile.txt', attachment.filename
103 assert_equal 'This is an attachment', attachment.description
103 assert_equal 'This is an attachment', attachment.description
104 # verify the size of the attachment stored in db
104 # verify the size of the attachment stored in db
105 #assert_equal file_data_1.length, attachment.filesize
105 #assert_equal file_data_1.length, attachment.filesize
106 # verify that the attachment was written to disk
106 # verify that the attachment was written to disk
107 assert File.exist?(attachment.diskfile)
107 assert File.exist?(attachment.diskfile)
108
108
109 # remove the attachments
109 # remove the attachments
110 Issue.find(1).attachments.each(&:destroy)
110 Issue.find(1).attachments.each(&:destroy)
111 assert_equal 0, Issue.find(1).attachments.length
111 assert_equal 0, Issue.find(1).attachments.length
112 end
112 end
113
113
114 def test_other_formats_links_on_index
114 def test_other_formats_links_on_index
115 get '/projects/ecookbook/issues'
115 get '/projects/ecookbook/issues'
116
116
117 %w(Atom PDF CSV).each do |format|
117 %w(Atom PDF CSV).each do |format|
118 assert_select 'a[rel=nofollow][href=?]', "/projects/ecookbook/issues.#{format.downcase}", :text => format
118 assert_select 'a[rel=nofollow][href=?]', "/projects/ecookbook/issues.#{format.downcase}", :text => format
119 end
119 end
120 end
120 end
121
121
122 def test_other_formats_links_on_index_without_project_id_in_url
122 def test_other_formats_links_on_index_without_project_id_in_url
123 get '/issues', :project_id => 'ecookbook'
123 get '/issues', :project_id => 'ecookbook'
124
124
125 %w(Atom PDF CSV).each do |format|
125 %w(Atom PDF CSV).each do |format|
126 assert_select 'a[rel=nofollow][href=?]', "/issues.#{format.downcase}?project_id=ecookbook", :text => format
126 assert_select 'a[rel=nofollow][href=?]', "/issues.#{format.downcase}?project_id=ecookbook", :text => format
127 end
127 end
128 end
128 end
129
129
130 def test_pagination_links_on_index
130 def test_pagination_links_on_index
131 with_settings :per_page_options => '2' do
131 with_settings :per_page_options => '2' do
132 get '/projects/ecookbook/issues'
132 get '/projects/ecookbook/issues'
133
133
134 assert_select 'a[href=?]', '/projects/ecookbook/issues?page=2', :text => '2'
134 assert_select 'a[href=?]', '/projects/ecookbook/issues?page=2', :text => '2'
135 end
135 end
136 end
136 end
137
137
138 def test_pagination_links_should_preserve_query_parameters
138 def test_pagination_links_should_preserve_query_parameters
139 with_settings :per_page_options => '2' do
139 with_settings :per_page_options => '2' do
140 get '/projects/ecookbook/issues?foo=bar'
140 get '/projects/ecookbook/issues?foo=bar'
141
141
142 assert_select 'a[href=?]', '/projects/ecookbook/issues?foo=bar&page=2', :text => '2'
142 assert_select 'a[href=?]', '/projects/ecookbook/issues?foo=bar&page=2', :text => '2'
143 end
143 end
144 end
144 end
145
145
146 def test_pagination_links_should_not_use_params_as_url_options
146 def test_pagination_links_should_not_use_params_as_url_options
147 with_settings :per_page_options => '2' do
147 with_settings :per_page_options => '2' do
148 get '/projects/ecookbook/issues?host=foo'
148 get '/projects/ecookbook/issues?host=foo'
149
149
150 assert_select 'a[href=?]', '/projects/ecookbook/issues?host=foo&page=2', :text => '2'
150 assert_select 'a[href=?]', '/projects/ecookbook/issues?host=foo&page=2', :text => '2'
151 end
151 end
152 end
152 end
153
153
154 def test_sort_links_on_index
154 def test_sort_links_on_index
155 get '/projects/ecookbook/issues'
155 get '/projects/ecookbook/issues'
156
156
157 assert_select 'a[href=?]', '/projects/ecookbook/issues?sort=subject%2Cid%3Adesc', :text => 'Subject'
157 assert_select 'a[href=?]', '/projects/ecookbook/issues?sort=subject%2Cid%3Adesc', :text => 'Subject'
158 end
158 end
159
159
160 def test_sort_links_should_preserve_query_parameters
160 def test_sort_links_should_preserve_query_parameters
161 get '/projects/ecookbook/issues?foo=bar'
161 get '/projects/ecookbook/issues?foo=bar'
162
162
163 assert_select 'a[href=?]', '/projects/ecookbook/issues?foo=bar&sort=subject%2Cid%3Adesc', :text => 'Subject'
163 assert_select 'a[href=?]', '/projects/ecookbook/issues?foo=bar&sort=subject%2Cid%3Adesc', :text => 'Subject'
164 end
164 end
165
165
166 def test_sort_links_should_not_use_params_as_url_options
166 def test_sort_links_should_not_use_params_as_url_options
167 get '/projects/ecookbook/issues?host=foo'
167 get '/projects/ecookbook/issues?host=foo'
168
168
169 assert_select 'a[href=?]', '/projects/ecookbook/issues?host=foo&sort=subject%2Cid%3Adesc', :text => 'Subject'
169 assert_select 'a[href=?]', '/projects/ecookbook/issues?host=foo&sort=subject%2Cid%3Adesc', :text => 'Subject'
170 end
170 end
171
171
172 def test_issue_with_user_custom_field
172 def test_issue_with_user_custom_field
173 @field = IssueCustomField.create!(:name => 'Tester', :field_format => 'user', :is_for_all => true, :trackers => Tracker.all)
173 @field = IssueCustomField.create!(:name => 'Tester', :field_format => 'user', :is_for_all => true, :trackers => Tracker.all)
174 Role.anonymous.add_permission! :add_issues, :edit_issues
174 Role.anonymous.add_permission! :add_issues, :edit_issues
175 users = Project.find(1).users.uniq.sort
175 users = Project.find(1).users.sort
176 tester = users.first
176 tester = users.first
177
177
178 # Issue form
178 # Issue form
179 get '/projects/ecookbook/issues/new'
179 get '/projects/ecookbook/issues/new'
180 assert_response :success
180 assert_response :success
181 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
181 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
182 assert_select 'option', users.size + 1 # +1 for blank value
182 assert_select 'option', users.size + 1 # +1 for blank value
183 assert_select 'option[value=?]', tester.id.to_s, :text => tester.name
183 assert_select 'option[value=?]', tester.id.to_s, :text => tester.name
184 end
184 end
185
185
186 # Create issue
186 # Create issue
187 issue = new_record(Issue) do
187 issue = new_record(Issue) do
188 post '/projects/ecookbook/issues',
188 post '/projects/ecookbook/issues',
189 :issue => {
189 :issue => {
190 :tracker_id => '1',
190 :tracker_id => '1',
191 :priority_id => '4',
191 :priority_id => '4',
192 :subject => 'Issue with user custom field',
192 :subject => 'Issue with user custom field',
193 :custom_field_values => {@field.id.to_s => users.first.id.to_s}
193 :custom_field_values => {@field.id.to_s => users.first.id.to_s}
194 }
194 }
195 assert_response 302
195 assert_response 302
196 end
196 end
197
197
198 # Issue view
198 # Issue view
199 follow_redirect!
199 follow_redirect!
200 assert_select ".cf_#{@field.id}" do
200 assert_select ".cf_#{@field.id}" do
201 assert_select '.label', :text => 'Tester:'
201 assert_select '.label', :text => 'Tester:'
202 assert_select '.value', :text => tester.name
202 assert_select '.value', :text => tester.name
203 end
203 end
204 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
204 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
205 assert_select 'option', users.size + 1 # +1 for blank value
205 assert_select 'option', users.size + 1 # +1 for blank value
206 assert_select 'option[value=?][selected=selected]', tester.id.to_s, :text => tester.name
206 assert_select 'option[value=?][selected=selected]', tester.id.to_s, :text => tester.name
207 end
207 end
208
208
209 new_tester = users[1]
209 new_tester = users[1]
210 with_settings :default_language => 'en' do
210 with_settings :default_language => 'en' do
211 # Update issue
211 # Update issue
212 assert_difference 'Journal.count' do
212 assert_difference 'Journal.count' do
213 put "/issues/#{issue.id}",
213 put "/issues/#{issue.id}",
214 :notes => 'Updating custom field',
214 :notes => 'Updating custom field',
215 :issue => {
215 :issue => {
216 :custom_field_values => {@field.id.to_s => new_tester.id.to_s}
216 :custom_field_values => {@field.id.to_s => new_tester.id.to_s}
217 }
217 }
218 assert_redirected_to "/issues/#{issue.id}"
218 assert_redirected_to "/issues/#{issue.id}"
219 end
219 end
220 # Issue view
220 # Issue view
221 follow_redirect!
221 follow_redirect!
222 assert_select 'ul.details li', :text => "Tester changed from #{tester} to #{new_tester}"
222 assert_select 'ul.details li', :text => "Tester changed from #{tester} to #{new_tester}"
223 end
223 end
224 end
224 end
225
225
226 def test_update_using_invalid_http_verbs
226 def test_update_using_invalid_http_verbs
227 subject = 'Updated by an invalid http verb'
227 subject = 'Updated by an invalid http verb'
228
228
229 get '/issues/update/1', {:issue => {:subject => subject}}, credentials('jsmith')
229 get '/issues/update/1', {:issue => {:subject => subject}}, credentials('jsmith')
230 assert_response 404
230 assert_response 404
231 assert_not_equal subject, Issue.find(1).subject
231 assert_not_equal subject, Issue.find(1).subject
232
232
233 post '/issues/1', {:issue => {:subject => subject}}, credentials('jsmith')
233 post '/issues/1', {:issue => {:subject => subject}}, credentials('jsmith')
234 assert_response 404
234 assert_response 404
235 assert_not_equal subject, Issue.find(1).subject
235 assert_not_equal subject, Issue.find(1).subject
236 end
236 end
237
237
238 def test_get_watch_should_be_invalid
238 def test_get_watch_should_be_invalid
239 assert_no_difference 'Watcher.count' do
239 assert_no_difference 'Watcher.count' do
240 get '/watchers/watch?object_type=issue&object_id=1', {}, credentials('jsmith')
240 get '/watchers/watch?object_type=issue&object_id=1', {}, credentials('jsmith')
241 assert_response 404
241 assert_response 404
242 end
242 end
243 end
243 end
244 end
244 end
@@ -1,264 +1,264
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class ProjectMembersInheritanceTest < ActiveSupport::TestCase
20 class ProjectMembersInheritanceTest < ActiveSupport::TestCase
21 fixtures :roles, :users,
21 fixtures :roles, :users,
22 :projects, :trackers, :issue_statuses
22 :projects, :trackers, :issue_statuses
23
23
24 def setup
24 def setup
25 @parent = Project.generate!
25 @parent = Project.generate!
26 @member = Member.create!(:principal => User.find(2), :project => @parent, :role_ids => [1, 2])
26 @member = Member.create!(:principal => User.find(2), :project => @parent, :role_ids => [1, 2])
27 assert_equal 2, @member.reload.roles.size
27 assert_equal 2, @member.reload.roles.size
28 end
28 end
29
29
30 def test_project_created_with_inherit_members_disabled_should_not_inherit_members
30 def test_project_created_with_inherit_members_disabled_should_not_inherit_members
31 assert_no_difference 'Member.count' do
31 assert_no_difference 'Member.count' do
32 project = Project.generate_with_parent!(@parent, :inherit_members => false)
32 project = Project.generate_with_parent!(@parent, :inherit_members => false)
33
33
34 assert_equal 0, project.memberships.count
34 assert_equal 0, project.memberships.count
35 end
35 end
36 end
36 end
37
37
38 def test_project_created_with_inherit_members_should_inherit_members
38 def test_project_created_with_inherit_members_should_inherit_members
39 assert_difference 'Member.count', 1 do
39 assert_difference 'Member.count', 1 do
40 project = Project.generate_with_parent!(@parent, :inherit_members => true)
40 project = Project.generate_with_parent!(@parent, :inherit_members => true)
41 project.reload
41 project.reload
42
42
43 assert_equal 1, project.memberships.count
43 assert_equal 1, project.memberships.count
44 member = project.memberships.first
44 member = project.memberships.first
45 assert_equal @member.principal, member.principal
45 assert_equal @member.principal, member.principal
46 assert_equal @member.roles.sort, member.roles.sort
46 assert_equal @member.roles.sort, member.roles.sort
47 end
47 end
48 end
48 end
49
49
50 def test_turning_on_inherit_members_should_inherit_members
50 def test_turning_on_inherit_members_should_inherit_members
51 Project.generate_with_parent!(@parent, :inherit_members => false)
51 Project.generate_with_parent!(@parent, :inherit_members => false)
52
52
53 assert_difference 'Member.count', 1 do
53 assert_difference 'Member.count', 1 do
54 project = Project.order('id desc').first
54 project = Project.order('id desc').first
55 project.inherit_members = true
55 project.inherit_members = true
56 project.save!
56 project.save!
57 project.reload
57 project.reload
58
58
59 assert_equal 1, project.memberships.count
59 assert_equal 1, project.memberships.count
60 member = project.memberships.first
60 member = project.memberships.first
61 assert_equal @member.principal, member.principal
61 assert_equal @member.principal, member.principal
62 assert_equal @member.roles.sort, member.roles.sort
62 assert_equal @member.roles.sort, member.roles.sort
63 end
63 end
64 end
64 end
65
65
66 def test_turning_off_inherit_members_should_remove_inherited_members
66 def test_turning_off_inherit_members_should_remove_inherited_members
67 Project.generate_with_parent!(@parent, :inherit_members => true)
67 Project.generate_with_parent!(@parent, :inherit_members => true)
68
68
69 assert_difference 'Member.count', -1 do
69 assert_difference 'Member.count', -1 do
70 project = Project.order('id desc').first
70 project = Project.order('id desc').first
71 project.inherit_members = false
71 project.inherit_members = false
72 project.save!
72 project.save!
73 project.reload
73 project.reload
74
74
75 assert_equal 0, project.memberships.count
75 assert_equal 0, project.memberships.count
76 end
76 end
77 end
77 end
78
78
79 def test_moving_a_root_project_under_a_parent_should_inherit_members
79 def test_moving_a_root_project_under_a_parent_should_inherit_members
80 Project.generate!(:inherit_members => true)
80 Project.generate!(:inherit_members => true)
81 project = Project.order('id desc').first
81 project = Project.order('id desc').first
82
82
83 assert_difference 'Member.count', 1 do
83 assert_difference 'Member.count', 1 do
84 project.set_parent!(@parent)
84 project.set_parent!(@parent)
85 project.reload
85 project.reload
86
86
87 assert_equal 1, project.memberships.count
87 assert_equal 1, project.memberships.count
88 member = project.memberships.first
88 member = project.memberships.first
89 assert_equal @member.principal, member.principal
89 assert_equal @member.principal, member.principal
90 assert_equal @member.roles.sort, member.roles.sort
90 assert_equal @member.roles.sort, member.roles.sort
91 end
91 end
92 end
92 end
93
93
94 def test_moving_a_subproject_as_root_should_loose_inherited_members
94 def test_moving_a_subproject_as_root_should_loose_inherited_members
95 Project.generate_with_parent!(@parent, :inherit_members => true)
95 Project.generate_with_parent!(@parent, :inherit_members => true)
96 project = Project.order('id desc').first
96 project = Project.order('id desc').first
97
97
98 assert_difference 'Member.count', -1 do
98 assert_difference 'Member.count', -1 do
99 project.set_parent!(nil)
99 project.set_parent!(nil)
100 project.reload
100 project.reload
101
101
102 assert_equal 0, project.memberships.count
102 assert_equal 0, project.memberships.count
103 end
103 end
104 end
104 end
105
105
106 def test_moving_a_subproject_to_another_parent_should_change_inherited_members
106 def test_moving_a_subproject_to_another_parent_should_change_inherited_members
107 other_parent = Project.generate!
107 other_parent = Project.generate!
108 other_member = Member.create!(:principal => User.find(4), :project => other_parent, :role_ids => [3])
108 other_member = Member.create!(:principal => User.find(4), :project => other_parent, :role_ids => [3])
109 other_member.reload
109 other_member.reload
110
110
111 Project.generate_with_parent!(@parent, :inherit_members => true)
111 Project.generate_with_parent!(@parent, :inherit_members => true)
112 project = Project.order('id desc').first
112 project = Project.order('id desc').first
113 project.set_parent!(other_parent.reload)
113 project.set_parent!(other_parent.reload)
114 project.reload
114 project.reload
115
115
116 assert_equal 1, project.memberships.count
116 assert_equal 1, project.memberships.count
117 member = project.memberships.first
117 member = project.memberships.first
118 assert_equal other_member.principal, member.principal
118 assert_equal other_member.principal, member.principal
119 assert_equal other_member.roles.sort, member.roles.sort
119 assert_equal other_member.roles.sort, member.roles.sort
120 end
120 end
121
121
122 def test_inheritance_should_propagate_to_subprojects
122 def test_inheritance_should_propagate_to_subprojects
123 project = Project.generate_with_parent!(@parent, :inherit_members => false)
123 project = Project.generate_with_parent!(@parent, :inherit_members => false)
124 subproject = Project.generate_with_parent!(project, :inherit_members => true)
124 subproject = Project.generate_with_parent!(project, :inherit_members => true)
125 project.reload
125 project.reload
126
126
127 assert_difference 'Member.count', 2 do
127 assert_difference 'Member.count', 2 do
128 project.inherit_members = true
128 project.inherit_members = true
129 project.save
129 project.save
130 project.reload
130 project.reload
131 subproject.reload
131 subproject.reload
132
132
133 assert_equal 1, project.memberships.count
133 assert_equal 1, project.memberships.count
134 assert_equal 1, subproject.memberships.count
134 assert_equal 1, subproject.memberships.count
135 member = subproject.memberships.first
135 member = subproject.memberships.first
136 assert_equal @member.principal, member.principal
136 assert_equal @member.principal, member.principal
137 assert_equal @member.roles.sort, member.roles.sort
137 assert_equal @member.roles.sort, member.roles.sort
138 end
138 end
139 end
139 end
140
140
141 def test_inheritance_removal_should_propagate_to_subprojects
141 def test_inheritance_removal_should_propagate_to_subprojects
142 project = Project.generate_with_parent!(@parent, :inherit_members => true)
142 project = Project.generate_with_parent!(@parent, :inherit_members => true)
143 subproject = Project.generate_with_parent!(project, :inherit_members => true)
143 subproject = Project.generate_with_parent!(project, :inherit_members => true)
144 project.reload
144 project.reload
145
145
146 assert_difference 'Member.count', -2 do
146 assert_difference 'Member.count', -2 do
147 project.inherit_members = false
147 project.inherit_members = false
148 project.save
148 project.save
149 project.reload
149 project.reload
150 subproject.reload
150 subproject.reload
151
151
152 assert_equal 0, project.memberships.count
152 assert_equal 0, project.memberships.count
153 assert_equal 0, subproject.memberships.count
153 assert_equal 0, subproject.memberships.count
154 end
154 end
155 end
155 end
156
156
157 def test_adding_a_member_should_propagate
157 def test_adding_a_member_should_propagate
158 project = Project.generate_with_parent!(@parent, :inherit_members => true)
158 project = Project.generate_with_parent!(@parent, :inherit_members => true)
159
159
160 assert_difference 'Member.count', 2 do
160 assert_difference 'Member.count', 2 do
161 member = Member.create!(:principal => User.find(4), :project => @parent, :role_ids => [1, 3])
161 member = Member.create!(:principal => User.find(4), :project => @parent, :role_ids => [1, 3])
162 member.reload
162 member.reload
163
163
164 inherited_member = project.memberships.order('id desc').first
164 inherited_member = project.memberships.order('id desc').first
165 assert_equal member.principal, inherited_member.principal
165 assert_equal member.principal, inherited_member.principal
166 assert_equal member.roles.sort, inherited_member.roles.sort
166 assert_equal member.roles.sort, inherited_member.roles.sort
167 end
167 end
168 end
168 end
169
169
170 def test_adding_a_member_should_not_propagate_if_child_does_not_inherit
170 def test_adding_a_member_should_not_propagate_if_child_does_not_inherit
171 project = Project.generate_with_parent!(@parent, :inherit_members => false)
171 project = Project.generate_with_parent!(@parent, :inherit_members => false)
172
172
173 assert_difference 'Member.count', 1 do
173 assert_difference 'Member.count', 1 do
174 member = Member.create!(:principal => User.find(4), :project => @parent, :role_ids => [1, 3])
174 member = Member.create!(:principal => User.find(4), :project => @parent, :role_ids => [1, 3])
175
175
176 assert_nil project.reload.memberships.detect {|m| m.principal == member.principal}
176 assert_nil project.reload.memberships.detect {|m| m.principal == member.principal}
177 end
177 end
178 end
178 end
179
179
180 def test_removing_a_member_should_propagate
180 def test_removing_a_member_should_propagate
181 project = Project.generate_with_parent!(@parent, :inherit_members => true)
181 project = Project.generate_with_parent!(@parent, :inherit_members => true)
182
182
183 assert_difference 'Member.count', -2 do
183 assert_difference 'Member.count', -2 do
184 @member.reload.destroy
184 @member.reload.destroy
185 project.reload
185 project.reload
186
186
187 assert_equal 0, project.memberships.count
187 assert_equal 0, project.memberships.count
188 end
188 end
189 end
189 end
190
190
191 def test_adding_a_group_member_should_propagate_with_its_users
191 def test_adding_a_group_member_should_propagate_with_its_users
192 project = Project.generate_with_parent!(@parent, :inherit_members => true)
192 project = Project.generate_with_parent!(@parent, :inherit_members => true)
193 group = Group.generate!
193 group = Group.generate!
194 user = User.find(4)
194 user = User.find(4)
195 group.users << user
195 group.users << user
196
196
197 assert_difference 'Member.count', 4 do
197 assert_difference 'Member.count', 4 do
198 assert_difference 'MemberRole.count', 8 do
198 assert_difference 'MemberRole.count', 8 do
199 member = Member.create!(:principal => group, :project => @parent, :role_ids => [1, 3])
199 member = Member.create!(:principal => group, :project => @parent, :role_ids => [1, 3])
200 project.reload
200 project.reload
201 member.reload
201 member.reload
202
202
203 inherited_group_member = project.memberships.detect {|m| m.principal == group}
203 inherited_group_member = project.memberships.detect {|m| m.principal == group}
204 assert_not_nil inherited_group_member
204 assert_not_nil inherited_group_member
205 assert_equal member.roles.sort, inherited_group_member.roles.sort
205 assert_equal member.roles.sort, inherited_group_member.roles.sort
206
206
207 inherited_user_member = project.memberships.detect {|m| m.principal == user}
207 inherited_user_member = project.memberships.detect {|m| m.principal == user}
208 assert_not_nil inherited_user_member
208 assert_not_nil inherited_user_member
209 assert_equal member.roles.sort, inherited_user_member.roles.sort
209 assert_equal member.roles.sort, inherited_user_member.roles.sort
210 end
210 end
211 end
211 end
212 end
212 end
213
213
214 def test_removing_a_group_member_should_propagate
214 def test_removing_a_group_member_should_propagate
215 project = Project.generate_with_parent!(@parent, :inherit_members => true)
215 project = Project.generate_with_parent!(@parent, :inherit_members => true)
216 group = Group.generate!
216 group = Group.generate!
217 user = User.find(4)
217 user = User.find(4)
218 group.users << user
218 group.users << user
219 member = Member.create!(:principal => group, :project => @parent, :role_ids => [1, 3])
219 member = Member.create!(:principal => group, :project => @parent, :role_ids => [1, 3])
220
220
221 assert_difference 'Member.count', -4 do
221 assert_difference 'Member.count', -4 do
222 assert_difference 'MemberRole.count', -8 do
222 assert_difference 'MemberRole.count', -8 do
223 member.destroy
223 member.destroy
224 project.reload
224 project.reload
225
225
226 inherited_group_member = project.memberships.detect {|m| m.principal == group}
226 inherited_group_member = project.memberships.detect {|m| m.principal == group}
227 assert_nil inherited_group_member
227 assert_nil inherited_group_member
228
228
229 inherited_user_member = project.memberships.detect {|m| m.principal == user}
229 inherited_user_member = project.memberships.detect {|m| m.principal == user}
230 assert_nil inherited_user_member
230 assert_nil inherited_user_member
231 end
231 end
232 end
232 end
233 end
233 end
234
234
235 def test_adding_user_who_use_is_already_a_member_to_parent_project_should_merge_roles
235 def test_adding_user_who_use_is_already_a_member_to_parent_project_should_merge_roles
236 project = Project.generate_with_parent!(@parent, :inherit_members => true)
236 project = Project.generate_with_parent!(@parent, :inherit_members => true)
237 user = User.find(4)
237 user = User.find(4)
238 Member.create!(:principal => user, :project => project, :role_ids => [1, 2])
238 Member.create!(:principal => user, :project => project, :role_ids => [1, 2])
239
239
240 assert_difference 'Member.count', 1 do
240 assert_difference 'Member.count', 1 do
241 Member.create!(:principal => User.find(4), :project => @parent.reload, :role_ids => [1, 3])
241 Member.create!(:principal => User.find(4), :project => @parent.reload, :role_ids => [1, 3])
242
242
243 member = project.reload.memberships.detect {|m| m.principal == user}
243 member = project.reload.memberships.detect {|m| m.principal == user}
244 assert_not_nil member
244 assert_not_nil member
245 assert_equal [1, 2, 3], member.roles.uniq.sort.map(&:id)
245 assert_equal [1, 2, 3], member.roles.map(&:id).uniq.sort
246 end
246 end
247 end
247 end
248
248
249 def test_turning_on_inheritance_with_user_who_is_already_a_member_should_merge_roles
249 def test_turning_on_inheritance_with_user_who_is_already_a_member_should_merge_roles
250 project = Project.generate_with_parent!(@parent)
250 project = Project.generate_with_parent!(@parent)
251 user = @member.user
251 user = @member.user
252 Member.create!(:principal => user, :project => project, :role_ids => [1, 3])
252 Member.create!(:principal => user, :project => project, :role_ids => [1, 3])
253 project.reload
253 project.reload
254
254
255 assert_no_difference 'Member.count' do
255 assert_no_difference 'Member.count' do
256 project.inherit_members = true
256 project.inherit_members = true
257 project.save!
257 project.save!
258
258
259 member = project.reload.memberships.detect {|m| m.principal == user}
259 member = project.reload.memberships.detect {|m| m.principal == user}
260 assert_not_nil member
260 assert_not_nil member
261 assert_equal [1, 2, 3], member.roles.uniq.sort.map(&:id)
261 assert_equal [1, 2, 3], member.roles.map(&:id).uniq.sort
262 end
262 end
263 end
263 end
264 end
264 end
@@ -1,1003 +1,1003
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class ProjectTest < ActiveSupport::TestCase
20 class ProjectTest < ActiveSupport::TestCase
21 fixtures :projects, :trackers, :issue_statuses, :issues,
21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 :journals, :journal_details,
22 :journals, :journal_details,
23 :enumerations, :users, :issue_categories,
23 :enumerations, :users, :issue_categories,
24 :projects_trackers,
24 :projects_trackers,
25 :custom_fields,
25 :custom_fields,
26 :custom_fields_projects,
26 :custom_fields_projects,
27 :custom_fields_trackers,
27 :custom_fields_trackers,
28 :custom_values,
28 :custom_values,
29 :roles,
29 :roles,
30 :member_roles,
30 :member_roles,
31 :members,
31 :members,
32 :enabled_modules,
32 :enabled_modules,
33 :versions,
33 :versions,
34 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
34 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
35 :groups_users,
35 :groups_users,
36 :boards, :messages,
36 :boards, :messages,
37 :repositories,
37 :repositories,
38 :news, :comments,
38 :news, :comments,
39 :documents,
39 :documents,
40 :workflows
40 :workflows
41
41
42 def setup
42 def setup
43 @ecookbook = Project.find(1)
43 @ecookbook = Project.find(1)
44 @ecookbook_sub1 = Project.find(3)
44 @ecookbook_sub1 = Project.find(3)
45 set_tmp_attachments_directory
45 set_tmp_attachments_directory
46 User.current = nil
46 User.current = nil
47 end
47 end
48
48
49 def test_truth
49 def test_truth
50 assert_kind_of Project, @ecookbook
50 assert_kind_of Project, @ecookbook
51 assert_equal "eCookbook", @ecookbook.name
51 assert_equal "eCookbook", @ecookbook.name
52 end
52 end
53
53
54 def test_default_attributes
54 def test_default_attributes
55 with_settings :default_projects_public => '1' do
55 with_settings :default_projects_public => '1' do
56 assert_equal true, Project.new.is_public
56 assert_equal true, Project.new.is_public
57 assert_equal false, Project.new(:is_public => false).is_public
57 assert_equal false, Project.new(:is_public => false).is_public
58 end
58 end
59
59
60 with_settings :default_projects_public => '0' do
60 with_settings :default_projects_public => '0' do
61 assert_equal false, Project.new.is_public
61 assert_equal false, Project.new.is_public
62 assert_equal true, Project.new(:is_public => true).is_public
62 assert_equal true, Project.new(:is_public => true).is_public
63 end
63 end
64
64
65 with_settings :sequential_project_identifiers => '1' do
65 with_settings :sequential_project_identifiers => '1' do
66 assert !Project.new.identifier.blank?
66 assert !Project.new.identifier.blank?
67 assert Project.new(:identifier => '').identifier.blank?
67 assert Project.new(:identifier => '').identifier.blank?
68 end
68 end
69
69
70 with_settings :sequential_project_identifiers => '0' do
70 with_settings :sequential_project_identifiers => '0' do
71 assert Project.new.identifier.blank?
71 assert Project.new.identifier.blank?
72 assert !Project.new(:identifier => 'test').blank?
72 assert !Project.new(:identifier => 'test').blank?
73 end
73 end
74
74
75 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
75 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
76 assert_equal ['issue_tracking', 'repository'], Project.new.enabled_module_names
76 assert_equal ['issue_tracking', 'repository'], Project.new.enabled_module_names
77 end
77 end
78 end
78 end
79
79
80 def test_default_trackers_should_match_default_tracker_ids_setting
80 def test_default_trackers_should_match_default_tracker_ids_setting
81 with_settings :default_projects_tracker_ids => ['1', '3'] do
81 with_settings :default_projects_tracker_ids => ['1', '3'] do
82 assert_equal Tracker.find(1, 3).sort, Project.new.trackers.sort
82 assert_equal Tracker.find(1, 3).sort, Project.new.trackers.sort
83 end
83 end
84 end
84 end
85
85
86 def test_default_trackers_should_be_all_trackers_with_blank_setting
86 def test_default_trackers_should_be_all_trackers_with_blank_setting
87 with_settings :default_projects_tracker_ids => nil do
87 with_settings :default_projects_tracker_ids => nil do
88 assert_equal Tracker.all.sort, Project.new.trackers.sort
88 assert_equal Tracker.all.sort, Project.new.trackers.sort
89 end
89 end
90 end
90 end
91
91
92 def test_default_trackers_should_be_empty_with_empty_setting
92 def test_default_trackers_should_be_empty_with_empty_setting
93 with_settings :default_projects_tracker_ids => [] do
93 with_settings :default_projects_tracker_ids => [] do
94 assert_equal [], Project.new.trackers
94 assert_equal [], Project.new.trackers
95 end
95 end
96 end
96 end
97
97
98 def test_default_trackers_should_not_replace_initialized_trackers
98 def test_default_trackers_should_not_replace_initialized_trackers
99 with_settings :default_projects_tracker_ids => ['1', '3'] do
99 with_settings :default_projects_tracker_ids => ['1', '3'] do
100 assert_equal Tracker.find(1, 2).sort, Project.new(:tracker_ids => [1, 2]).trackers.sort
100 assert_equal Tracker.find(1, 2).sort, Project.new(:tracker_ids => [1, 2]).trackers.sort
101 end
101 end
102 end
102 end
103
103
104 def test_update
104 def test_update
105 assert_equal "eCookbook", @ecookbook.name
105 assert_equal "eCookbook", @ecookbook.name
106 @ecookbook.name = "eCook"
106 @ecookbook.name = "eCook"
107 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
107 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
108 @ecookbook.reload
108 @ecookbook.reload
109 assert_equal "eCook", @ecookbook.name
109 assert_equal "eCook", @ecookbook.name
110 end
110 end
111
111
112 def test_validate_identifier
112 def test_validate_identifier
113 to_test = {"abc" => true,
113 to_test = {"abc" => true,
114 "ab12" => true,
114 "ab12" => true,
115 "ab-12" => true,
115 "ab-12" => true,
116 "ab_12" => true,
116 "ab_12" => true,
117 "12" => false,
117 "12" => false,
118 "new" => false}
118 "new" => false}
119
119
120 to_test.each do |identifier, valid|
120 to_test.each do |identifier, valid|
121 p = Project.new
121 p = Project.new
122 p.identifier = identifier
122 p.identifier = identifier
123 p.valid?
123 p.valid?
124 if valid
124 if valid
125 assert p.errors['identifier'].blank?, "identifier #{identifier} was not valid"
125 assert p.errors['identifier'].blank?, "identifier #{identifier} was not valid"
126 else
126 else
127 assert p.errors['identifier'].present?, "identifier #{identifier} was valid"
127 assert p.errors['identifier'].present?, "identifier #{identifier} was valid"
128 end
128 end
129 end
129 end
130 end
130 end
131
131
132 def test_identifier_should_not_be_frozen_for_a_new_project
132 def test_identifier_should_not_be_frozen_for_a_new_project
133 assert_equal false, Project.new.identifier_frozen?
133 assert_equal false, Project.new.identifier_frozen?
134 end
134 end
135
135
136 def test_identifier_should_not_be_frozen_for_a_saved_project_with_blank_identifier
136 def test_identifier_should_not_be_frozen_for_a_saved_project_with_blank_identifier
137 Project.where(:id => 1).update_all(["identifier = ''"])
137 Project.where(:id => 1).update_all(["identifier = ''"])
138 assert_equal false, Project.find(1).identifier_frozen?
138 assert_equal false, Project.find(1).identifier_frozen?
139 end
139 end
140
140
141 def test_identifier_should_be_frozen_for_a_saved_project_with_valid_identifier
141 def test_identifier_should_be_frozen_for_a_saved_project_with_valid_identifier
142 assert_equal true, Project.find(1).identifier_frozen?
142 assert_equal true, Project.find(1).identifier_frozen?
143 end
143 end
144
144
145 def test_to_param_should_be_nil_for_new_records
145 def test_to_param_should_be_nil_for_new_records
146 project = Project.new
146 project = Project.new
147 project.identifier = "foo"
147 project.identifier = "foo"
148 assert_nil project.to_param
148 assert_nil project.to_param
149 end
149 end
150
150
151 def test_members_should_be_active_users
151 def test_members_should_be_active_users
152 Project.all.each do |project|
152 Project.all.each do |project|
153 assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) }
153 assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) }
154 end
154 end
155 end
155 end
156
156
157 def test_users_should_be_active_users
157 def test_users_should_be_active_users
158 Project.all.each do |project|
158 Project.all.each do |project|
159 assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) }
159 assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) }
160 end
160 end
161 end
161 end
162
162
163 def test_open_scope_on_issues_association
163 def test_open_scope_on_issues_association
164 assert_kind_of Issue, Project.find(1).issues.open.first
164 assert_kind_of Issue, Project.find(1).issues.open.first
165 end
165 end
166
166
167 def test_archive
167 def test_archive
168 user = @ecookbook.members.first.user
168 user = @ecookbook.members.first.user
169 @ecookbook.archive
169 @ecookbook.archive
170 @ecookbook.reload
170 @ecookbook.reload
171
171
172 assert !@ecookbook.active?
172 assert !@ecookbook.active?
173 assert @ecookbook.archived?
173 assert @ecookbook.archived?
174 assert !user.projects.include?(@ecookbook)
174 assert !user.projects.include?(@ecookbook)
175 # Subproject are also archived
175 # Subproject are also archived
176 assert !@ecookbook.children.empty?
176 assert !@ecookbook.children.empty?
177 assert @ecookbook.descendants.active.empty?
177 assert @ecookbook.descendants.active.empty?
178 end
178 end
179
179
180 def test_archive_should_fail_if_versions_are_used_by_non_descendant_projects
180 def test_archive_should_fail_if_versions_are_used_by_non_descendant_projects
181 # Assign an issue of a project to a version of a child project
181 # Assign an issue of a project to a version of a child project
182 Issue.find(4).update_attribute :fixed_version_id, 4
182 Issue.find(4).update_attribute :fixed_version_id, 4
183
183
184 assert_no_difference "Project.where(:status => Project::STATUS_ARCHIVED).count" do
184 assert_no_difference "Project.where(:status => Project::STATUS_ARCHIVED).count" do
185 assert_equal false, @ecookbook.archive
185 assert_equal false, @ecookbook.archive
186 end
186 end
187 @ecookbook.reload
187 @ecookbook.reload
188 assert @ecookbook.active?
188 assert @ecookbook.active?
189 end
189 end
190
190
191 def test_unarchive
191 def test_unarchive
192 user = @ecookbook.members.first.user
192 user = @ecookbook.members.first.user
193 @ecookbook.archive
193 @ecookbook.archive
194 # A subproject of an archived project can not be unarchived
194 # A subproject of an archived project can not be unarchived
195 assert !@ecookbook_sub1.unarchive
195 assert !@ecookbook_sub1.unarchive
196
196
197 # Unarchive project
197 # Unarchive project
198 assert @ecookbook.unarchive
198 assert @ecookbook.unarchive
199 @ecookbook.reload
199 @ecookbook.reload
200 assert @ecookbook.active?
200 assert @ecookbook.active?
201 assert !@ecookbook.archived?
201 assert !@ecookbook.archived?
202 assert user.projects.include?(@ecookbook)
202 assert user.projects.include?(@ecookbook)
203 # Subproject can now be unarchived
203 # Subproject can now be unarchived
204 @ecookbook_sub1.reload
204 @ecookbook_sub1.reload
205 assert @ecookbook_sub1.unarchive
205 assert @ecookbook_sub1.unarchive
206 end
206 end
207
207
208 def test_destroy
208 def test_destroy
209 # 2 active members
209 # 2 active members
210 assert_equal 2, @ecookbook.members.size
210 assert_equal 2, @ecookbook.members.size
211 # and 1 is locked
211 # and 1 is locked
212 assert_equal 3, Member.where(:project_id => @ecookbook.id).count
212 assert_equal 3, Member.where(:project_id => @ecookbook.id).count
213 # some boards
213 # some boards
214 assert @ecookbook.boards.any?
214 assert @ecookbook.boards.any?
215
215
216 @ecookbook.destroy
216 @ecookbook.destroy
217 # make sure that the project non longer exists
217 # make sure that the project non longer exists
218 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
218 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
219 # make sure related data was removed
219 # make sure related data was removed
220 assert_nil Member.where(:project_id => @ecookbook.id).first
220 assert_nil Member.where(:project_id => @ecookbook.id).first
221 assert_nil Board.where(:project_id => @ecookbook.id).first
221 assert_nil Board.where(:project_id => @ecookbook.id).first
222 assert_nil Issue.where(:project_id => @ecookbook.id).first
222 assert_nil Issue.where(:project_id => @ecookbook.id).first
223 end
223 end
224
224
225 def test_destroy_should_destroy_subtasks
225 def test_destroy_should_destroy_subtasks
226 issues = (0..2).to_a.map {Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test')}
226 issues = (0..2).to_a.map {Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test')}
227 issues[0].update_attribute :parent_issue_id, issues[1].id
227 issues[0].update_attribute :parent_issue_id, issues[1].id
228 issues[2].update_attribute :parent_issue_id, issues[1].id
228 issues[2].update_attribute :parent_issue_id, issues[1].id
229 assert_equal 2, issues[1].children.count
229 assert_equal 2, issues[1].children.count
230
230
231 assert_nothing_raised do
231 assert_nothing_raised do
232 Project.find(1).destroy
232 Project.find(1).destroy
233 end
233 end
234 assert_equal 0, Issue.where(:id => issues.map(&:id)).count
234 assert_equal 0, Issue.where(:id => issues.map(&:id)).count
235 end
235 end
236
236
237 def test_destroying_root_projects_should_clear_data
237 def test_destroying_root_projects_should_clear_data
238 Project.roots.each do |root|
238 Project.roots.each do |root|
239 root.destroy
239 root.destroy
240 end
240 end
241
241
242 assert_equal 0, Project.count, "Projects were not deleted: #{Project.all.inspect}"
242 assert_equal 0, Project.count, "Projects were not deleted: #{Project.all.inspect}"
243 assert_equal 0, Member.count, "Members were not deleted: #{Member.all.inspect}"
243 assert_equal 0, Member.count, "Members were not deleted: #{Member.all.inspect}"
244 assert_equal 0, MemberRole.count
244 assert_equal 0, MemberRole.count
245 assert_equal 0, Issue.count
245 assert_equal 0, Issue.count
246 assert_equal 0, Journal.count
246 assert_equal 0, Journal.count
247 assert_equal 0, JournalDetail.count
247 assert_equal 0, JournalDetail.count
248 assert_equal 0, Attachment.count, "Attachments were not deleted: #{Attachment.all.inspect}"
248 assert_equal 0, Attachment.count, "Attachments were not deleted: #{Attachment.all.inspect}"
249 assert_equal 0, EnabledModule.count
249 assert_equal 0, EnabledModule.count
250 assert_equal 0, IssueCategory.count
250 assert_equal 0, IssueCategory.count
251 assert_equal 0, IssueRelation.count
251 assert_equal 0, IssueRelation.count
252 assert_equal 0, Board.count
252 assert_equal 0, Board.count
253 assert_equal 0, Message.count
253 assert_equal 0, Message.count
254 assert_equal 0, News.count
254 assert_equal 0, News.count
255 assert_equal 0, Query.where("project_id IS NOT NULL").count
255 assert_equal 0, Query.where("project_id IS NOT NULL").count
256 assert_equal 0, Repository.count
256 assert_equal 0, Repository.count
257 assert_equal 0, Changeset.count
257 assert_equal 0, Changeset.count
258 assert_equal 0, Change.count
258 assert_equal 0, Change.count
259 assert_equal 0, Comment.count
259 assert_equal 0, Comment.count
260 assert_equal 0, TimeEntry.count
260 assert_equal 0, TimeEntry.count
261 assert_equal 0, Version.count
261 assert_equal 0, Version.count
262 assert_equal 0, Watcher.count
262 assert_equal 0, Watcher.count
263 assert_equal 0, Wiki.count
263 assert_equal 0, Wiki.count
264 assert_equal 0, WikiPage.count
264 assert_equal 0, WikiPage.count
265 assert_equal 0, WikiContent.count
265 assert_equal 0, WikiContent.count
266 assert_equal 0, WikiContent::Version.count
266 assert_equal 0, WikiContent::Version.count
267 assert_equal 0, Project.connection.select_all("SELECT * FROM projects_trackers").count
267 assert_equal 0, Project.connection.select_all("SELECT * FROM projects_trackers").count
268 assert_equal 0, Project.connection.select_all("SELECT * FROM custom_fields_projects").count
268 assert_equal 0, Project.connection.select_all("SELECT * FROM custom_fields_projects").count
269 assert_equal 0, CustomValue.where(:customized_type => ['Project', 'Issue', 'TimeEntry', 'Version']).count
269 assert_equal 0, CustomValue.where(:customized_type => ['Project', 'Issue', 'TimeEntry', 'Version']).count
270 end
270 end
271
271
272 def test_destroy_should_delete_time_entries_custom_values
272 def test_destroy_should_delete_time_entries_custom_values
273 project = Project.generate!
273 project = Project.generate!
274 time_entry = TimeEntry.generate!(:project => project, :custom_field_values => {10 => '1'})
274 time_entry = TimeEntry.generate!(:project => project, :custom_field_values => {10 => '1'})
275
275
276 assert_difference 'CustomValue.where(:customized_type => "TimeEntry").count', -1 do
276 assert_difference 'CustomValue.where(:customized_type => "TimeEntry").count', -1 do
277 assert project.destroy
277 assert project.destroy
278 end
278 end
279 end
279 end
280
280
281 def test_move_an_orphan_project_to_a_root_project
281 def test_move_an_orphan_project_to_a_root_project
282 sub = Project.find(2)
282 sub = Project.find(2)
283 sub.set_parent! @ecookbook
283 sub.set_parent! @ecookbook
284 assert_equal @ecookbook.id, sub.parent.id
284 assert_equal @ecookbook.id, sub.parent.id
285 @ecookbook.reload
285 @ecookbook.reload
286 assert_equal 4, @ecookbook.children.size
286 assert_equal 4, @ecookbook.children.size
287 end
287 end
288
288
289 def test_move_an_orphan_project_to_a_subproject
289 def test_move_an_orphan_project_to_a_subproject
290 sub = Project.find(2)
290 sub = Project.find(2)
291 assert sub.set_parent!(@ecookbook_sub1)
291 assert sub.set_parent!(@ecookbook_sub1)
292 end
292 end
293
293
294 def test_move_a_root_project_to_a_project
294 def test_move_a_root_project_to_a_project
295 sub = @ecookbook
295 sub = @ecookbook
296 assert sub.set_parent!(Project.find(2))
296 assert sub.set_parent!(Project.find(2))
297 end
297 end
298
298
299 def test_should_not_move_a_project_to_its_children
299 def test_should_not_move_a_project_to_its_children
300 sub = @ecookbook
300 sub = @ecookbook
301 assert !(sub.set_parent!(Project.find(3)))
301 assert !(sub.set_parent!(Project.find(3)))
302 end
302 end
303
303
304 def test_set_parent_should_add_roots_in_alphabetical_order
304 def test_set_parent_should_add_roots_in_alphabetical_order
305 ProjectCustomField.delete_all
305 ProjectCustomField.delete_all
306 Project.delete_all
306 Project.delete_all
307 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
307 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
308 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
308 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
309 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
309 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
310 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
310 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
311
311
312 assert_equal 4, Project.count
312 assert_equal 4, Project.count
313 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
313 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
314 end
314 end
315
315
316 def test_set_parent_should_add_children_in_alphabetical_order
316 def test_set_parent_should_add_children_in_alphabetical_order
317 ProjectCustomField.delete_all
317 ProjectCustomField.delete_all
318 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
318 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
319 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
319 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
320 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
320 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
321 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
321 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
322 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
322 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
323
323
324 parent.reload
324 parent.reload
325 assert_equal 4, parent.children.size
325 assert_equal 4, parent.children.size
326 assert_equal parent.children.sort_by(&:name), parent.children.to_a
326 assert_equal parent.children.sort_by(&:name), parent.children.to_a
327 end
327 end
328
328
329 def test_set_parent_should_update_issue_fixed_version_associations_when_a_fixed_version_is_moved_out_of_the_hierarchy
329 def test_set_parent_should_update_issue_fixed_version_associations_when_a_fixed_version_is_moved_out_of_the_hierarchy
330 # Parent issue with a hierarchy project's fixed version
330 # Parent issue with a hierarchy project's fixed version
331 parent_issue = Issue.find(1)
331 parent_issue = Issue.find(1)
332 parent_issue.update_attribute(:fixed_version_id, 4)
332 parent_issue.update_attribute(:fixed_version_id, 4)
333 parent_issue.reload
333 parent_issue.reload
334 assert_equal 4, parent_issue.fixed_version_id
334 assert_equal 4, parent_issue.fixed_version_id
335
335
336 # Should keep fixed versions for the issues
336 # Should keep fixed versions for the issues
337 issue_with_local_fixed_version = Issue.find(5)
337 issue_with_local_fixed_version = Issue.find(5)
338 issue_with_local_fixed_version.update_attribute(:fixed_version_id, 4)
338 issue_with_local_fixed_version.update_attribute(:fixed_version_id, 4)
339 issue_with_local_fixed_version.reload
339 issue_with_local_fixed_version.reload
340 assert_equal 4, issue_with_local_fixed_version.fixed_version_id
340 assert_equal 4, issue_with_local_fixed_version.fixed_version_id
341
341
342 # Local issue with hierarchy fixed_version
342 # Local issue with hierarchy fixed_version
343 issue_with_hierarchy_fixed_version = Issue.find(13)
343 issue_with_hierarchy_fixed_version = Issue.find(13)
344 issue_with_hierarchy_fixed_version.update_attribute(:fixed_version_id, 6)
344 issue_with_hierarchy_fixed_version.update_attribute(:fixed_version_id, 6)
345 issue_with_hierarchy_fixed_version.reload
345 issue_with_hierarchy_fixed_version.reload
346 assert_equal 6, issue_with_hierarchy_fixed_version.fixed_version_id
346 assert_equal 6, issue_with_hierarchy_fixed_version.fixed_version_id
347
347
348 # Move project out of the issue's hierarchy
348 # Move project out of the issue's hierarchy
349 moved_project = Project.find(3)
349 moved_project = Project.find(3)
350 moved_project.set_parent!(Project.find(2))
350 moved_project.set_parent!(Project.find(2))
351 parent_issue.reload
351 parent_issue.reload
352 issue_with_local_fixed_version.reload
352 issue_with_local_fixed_version.reload
353 issue_with_hierarchy_fixed_version.reload
353 issue_with_hierarchy_fixed_version.reload
354
354
355 assert_equal 4, issue_with_local_fixed_version.fixed_version_id, "Fixed version was not keep on an issue local to the moved project"
355 assert_equal 4, issue_with_local_fixed_version.fixed_version_id, "Fixed version was not keep on an issue local to the moved project"
356 assert_equal nil, issue_with_hierarchy_fixed_version.fixed_version_id, "Fixed version is still set after moving the Project out of the hierarchy where the version is defined in"
356 assert_equal nil, issue_with_hierarchy_fixed_version.fixed_version_id, "Fixed version is still set after moving the Project out of the hierarchy where the version is defined in"
357 assert_equal nil, parent_issue.fixed_version_id, "Fixed version is still set after moving the Version out of the hierarchy for the issue."
357 assert_equal nil, parent_issue.fixed_version_id, "Fixed version is still set after moving the Version out of the hierarchy for the issue."
358 end
358 end
359
359
360 def test_parent
360 def test_parent
361 p = Project.find(6).parent
361 p = Project.find(6).parent
362 assert p.is_a?(Project)
362 assert p.is_a?(Project)
363 assert_equal 5, p.id
363 assert_equal 5, p.id
364 end
364 end
365
365
366 def test_ancestors
366 def test_ancestors
367 a = Project.find(6).ancestors
367 a = Project.find(6).ancestors
368 assert a.first.is_a?(Project)
368 assert a.first.is_a?(Project)
369 assert_equal [1, 5], a.collect(&:id)
369 assert_equal [1, 5], a.collect(&:id)
370 end
370 end
371
371
372 def test_root
372 def test_root
373 r = Project.find(6).root
373 r = Project.find(6).root
374 assert r.is_a?(Project)
374 assert r.is_a?(Project)
375 assert_equal 1, r.id
375 assert_equal 1, r.id
376 end
376 end
377
377
378 def test_children
378 def test_children
379 c = Project.find(1).children
379 c = Project.find(1).children
380 assert c.first.is_a?(Project)
380 assert c.first.is_a?(Project)
381 assert_equal [5, 3, 4], c.collect(&:id)
381 assert_equal [5, 3, 4], c.collect(&:id)
382 end
382 end
383
383
384 def test_descendants
384 def test_descendants
385 d = Project.find(1).descendants
385 d = Project.find(1).descendants
386 assert d.first.is_a?(Project)
386 assert d.first.is_a?(Project)
387 assert_equal [5, 6, 3, 4], d.collect(&:id)
387 assert_equal [5, 6, 3, 4], d.collect(&:id)
388 end
388 end
389
389
390 def test_allowed_parents_should_be_empty_for_non_member_user
390 def test_allowed_parents_should_be_empty_for_non_member_user
391 Role.non_member.add_permission!(:add_project)
391 Role.non_member.add_permission!(:add_project)
392 user = User.find(9)
392 user = User.find(9)
393 assert user.memberships.empty?
393 assert user.memberships.empty?
394 User.current = user
394 User.current = user
395 assert Project.new.allowed_parents.compact.empty?
395 assert Project.new.allowed_parents.compact.empty?
396 end
396 end
397
397
398 def test_allowed_parents_with_add_subprojects_permission
398 def test_allowed_parents_with_add_subprojects_permission
399 Role.find(1).remove_permission!(:add_project)
399 Role.find(1).remove_permission!(:add_project)
400 Role.find(1).add_permission!(:add_subprojects)
400 Role.find(1).add_permission!(:add_subprojects)
401 User.current = User.find(2)
401 User.current = User.find(2)
402 # new project
402 # new project
403 assert !Project.new.allowed_parents.include?(nil)
403 assert !Project.new.allowed_parents.include?(nil)
404 assert Project.new.allowed_parents.include?(Project.find(1))
404 assert Project.new.allowed_parents.include?(Project.find(1))
405 # existing root project
405 # existing root project
406 assert Project.find(1).allowed_parents.include?(nil)
406 assert Project.find(1).allowed_parents.include?(nil)
407 # existing child
407 # existing child
408 assert Project.find(3).allowed_parents.include?(Project.find(1))
408 assert Project.find(3).allowed_parents.include?(Project.find(1))
409 assert !Project.find(3).allowed_parents.include?(nil)
409 assert !Project.find(3).allowed_parents.include?(nil)
410 end
410 end
411
411
412 def test_allowed_parents_with_add_project_permission
412 def test_allowed_parents_with_add_project_permission
413 Role.find(1).add_permission!(:add_project)
413 Role.find(1).add_permission!(:add_project)
414 Role.find(1).remove_permission!(:add_subprojects)
414 Role.find(1).remove_permission!(:add_subprojects)
415 User.current = User.find(2)
415 User.current = User.find(2)
416 # new project
416 # new project
417 assert Project.new.allowed_parents.include?(nil)
417 assert Project.new.allowed_parents.include?(nil)
418 assert !Project.new.allowed_parents.include?(Project.find(1))
418 assert !Project.new.allowed_parents.include?(Project.find(1))
419 # existing root project
419 # existing root project
420 assert Project.find(1).allowed_parents.include?(nil)
420 assert Project.find(1).allowed_parents.include?(nil)
421 # existing child
421 # existing child
422 assert Project.find(3).allowed_parents.include?(Project.find(1))
422 assert Project.find(3).allowed_parents.include?(Project.find(1))
423 assert Project.find(3).allowed_parents.include?(nil)
423 assert Project.find(3).allowed_parents.include?(nil)
424 end
424 end
425
425
426 def test_allowed_parents_with_add_project_and_subprojects_permission
426 def test_allowed_parents_with_add_project_and_subprojects_permission
427 Role.find(1).add_permission!(:add_project)
427 Role.find(1).add_permission!(:add_project)
428 Role.find(1).add_permission!(:add_subprojects)
428 Role.find(1).add_permission!(:add_subprojects)
429 User.current = User.find(2)
429 User.current = User.find(2)
430 # new project
430 # new project
431 assert Project.new.allowed_parents.include?(nil)
431 assert Project.new.allowed_parents.include?(nil)
432 assert Project.new.allowed_parents.include?(Project.find(1))
432 assert Project.new.allowed_parents.include?(Project.find(1))
433 # existing root project
433 # existing root project
434 assert Project.find(1).allowed_parents.include?(nil)
434 assert Project.find(1).allowed_parents.include?(nil)
435 # existing child
435 # existing child
436 assert Project.find(3).allowed_parents.include?(Project.find(1))
436 assert Project.find(3).allowed_parents.include?(Project.find(1))
437 assert Project.find(3).allowed_parents.include?(nil)
437 assert Project.find(3).allowed_parents.include?(nil)
438 end
438 end
439
439
440 def test_users_by_role
440 def test_users_by_role
441 users_by_role = Project.find(1).users_by_role
441 users_by_role = Project.find(1).users_by_role
442 assert_kind_of Hash, users_by_role
442 assert_kind_of Hash, users_by_role
443 role = Role.find(1)
443 role = Role.find(1)
444 assert_kind_of Array, users_by_role[role]
444 assert_kind_of Array, users_by_role[role]
445 assert users_by_role[role].include?(User.find(2))
445 assert users_by_role[role].include?(User.find(2))
446 end
446 end
447
447
448 def test_rolled_up_trackers
448 def test_rolled_up_trackers
449 parent = Project.find(1)
449 parent = Project.find(1)
450 parent.trackers = Tracker.find([1,2])
450 parent.trackers = Tracker.find([1,2])
451 child = parent.children.find(3)
451 child = parent.children.find(3)
452
452
453 assert_equal [1, 2], parent.tracker_ids
453 assert_equal [1, 2], parent.tracker_ids
454 assert_equal [2, 3], child.trackers.collect(&:id)
454 assert_equal [2, 3], child.trackers.collect(&:id)
455
455
456 assert_kind_of Tracker, parent.rolled_up_trackers.first
456 assert_kind_of Tracker, parent.rolled_up_trackers.first
457 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
457 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
458
458
459 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
459 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
460 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
460 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
461 end
461 end
462
462
463 def test_rolled_up_trackers_should_ignore_archived_subprojects
463 def test_rolled_up_trackers_should_ignore_archived_subprojects
464 parent = Project.find(1)
464 parent = Project.find(1)
465 parent.trackers = Tracker.find([1,2])
465 parent.trackers = Tracker.find([1,2])
466 child = parent.children.find(3)
466 child = parent.children.find(3)
467 child.trackers = Tracker.find([1,3])
467 child.trackers = Tracker.find([1,3])
468 parent.children.each(&:archive)
468 parent.children.each(&:archive)
469
469
470 assert_equal [1,2], parent.rolled_up_trackers.collect(&:id)
470 assert_equal [1,2], parent.rolled_up_trackers.collect(&:id)
471 end
471 end
472
472
473 test "#rolled_up_trackers should ignore projects with issue_tracking module disabled" do
473 test "#rolled_up_trackers should ignore projects with issue_tracking module disabled" do
474 parent = Project.generate!
474 parent = Project.generate!
475 parent.trackers = Tracker.find([1, 2])
475 parent.trackers = Tracker.find([1, 2])
476 child = Project.generate_with_parent!(parent)
476 child = Project.generate_with_parent!(parent)
477 child.trackers = Tracker.find([2, 3])
477 child.trackers = Tracker.find([2, 3])
478
478
479 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id).sort
479 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id).sort
480
480
481 assert child.disable_module!(:issue_tracking)
481 assert child.disable_module!(:issue_tracking)
482 parent.reload
482 parent.reload
483 assert_equal [1, 2], parent.rolled_up_trackers.collect(&:id).sort
483 assert_equal [1, 2], parent.rolled_up_trackers.collect(&:id).sort
484 end
484 end
485
485
486 test "#rolled_up_versions should include the versions for the current project" do
486 test "#rolled_up_versions should include the versions for the current project" do
487 project = Project.generate!
487 project = Project.generate!
488 parent_version_1 = Version.generate!(:project => project)
488 parent_version_1 = Version.generate!(:project => project)
489 parent_version_2 = Version.generate!(:project => project)
489 parent_version_2 = Version.generate!(:project => project)
490 assert_equal [parent_version_1, parent_version_2].sort,
490 assert_equal [parent_version_1, parent_version_2].sort,
491 project.rolled_up_versions.sort
491 project.rolled_up_versions.sort
492 end
492 end
493
493
494 test "#rolled_up_versions should include versions for a subproject" do
494 test "#rolled_up_versions should include versions for a subproject" do
495 project = Project.generate!
495 project = Project.generate!
496 parent_version_1 = Version.generate!(:project => project)
496 parent_version_1 = Version.generate!(:project => project)
497 parent_version_2 = Version.generate!(:project => project)
497 parent_version_2 = Version.generate!(:project => project)
498 subproject = Project.generate_with_parent!(project)
498 subproject = Project.generate_with_parent!(project)
499 subproject_version = Version.generate!(:project => subproject)
499 subproject_version = Version.generate!(:project => subproject)
500
500
501 assert_equal [parent_version_1, parent_version_2, subproject_version].sort,
501 assert_equal [parent_version_1, parent_version_2, subproject_version].sort,
502 project.rolled_up_versions.sort
502 project.rolled_up_versions.sort
503 end
503 end
504
504
505 test "#rolled_up_versions should include versions for a sub-subproject" do
505 test "#rolled_up_versions should include versions for a sub-subproject" do
506 project = Project.generate!
506 project = Project.generate!
507 parent_version_1 = Version.generate!(:project => project)
507 parent_version_1 = Version.generate!(:project => project)
508 parent_version_2 = Version.generate!(:project => project)
508 parent_version_2 = Version.generate!(:project => project)
509 subproject = Project.generate_with_parent!(project)
509 subproject = Project.generate_with_parent!(project)
510 sub_subproject = Project.generate_with_parent!(subproject)
510 sub_subproject = Project.generate_with_parent!(subproject)
511 sub_subproject_version = Version.generate!(:project => sub_subproject)
511 sub_subproject_version = Version.generate!(:project => sub_subproject)
512 project.reload
512 project.reload
513
513
514 assert_equal [parent_version_1, parent_version_2, sub_subproject_version].sort,
514 assert_equal [parent_version_1, parent_version_2, sub_subproject_version].sort,
515 project.rolled_up_versions.sort
515 project.rolled_up_versions.sort
516 end
516 end
517
517
518 test "#rolled_up_versions should only check active projects" do
518 test "#rolled_up_versions should only check active projects" do
519 project = Project.generate!
519 project = Project.generate!
520 parent_version_1 = Version.generate!(:project => project)
520 parent_version_1 = Version.generate!(:project => project)
521 parent_version_2 = Version.generate!(:project => project)
521 parent_version_2 = Version.generate!(:project => project)
522 subproject = Project.generate_with_parent!(project)
522 subproject = Project.generate_with_parent!(project)
523 subproject_version = Version.generate!(:project => subproject)
523 subproject_version = Version.generate!(:project => subproject)
524 assert subproject.archive
524 assert subproject.archive
525 project.reload
525 project.reload
526
526
527 assert !subproject.active?
527 assert !subproject.active?
528 assert_equal [parent_version_1, parent_version_2].sort,
528 assert_equal [parent_version_1, parent_version_2].sort,
529 project.rolled_up_versions.sort
529 project.rolled_up_versions.sort
530 end
530 end
531
531
532 def test_shared_versions_none_sharing
532 def test_shared_versions_none_sharing
533 p = Project.find(5)
533 p = Project.find(5)
534 v = Version.create!(:name => 'none_sharing', :project => p, :sharing => 'none')
534 v = Version.create!(:name => 'none_sharing', :project => p, :sharing => 'none')
535 assert p.shared_versions.include?(v)
535 assert p.shared_versions.include?(v)
536 assert !p.children.first.shared_versions.include?(v)
536 assert !p.children.first.shared_versions.include?(v)
537 assert !p.root.shared_versions.include?(v)
537 assert !p.root.shared_versions.include?(v)
538 assert !p.siblings.first.shared_versions.include?(v)
538 assert !p.siblings.first.shared_versions.include?(v)
539 assert !p.root.siblings.first.shared_versions.include?(v)
539 assert !p.root.siblings.first.shared_versions.include?(v)
540 end
540 end
541
541
542 def test_shared_versions_descendants_sharing
542 def test_shared_versions_descendants_sharing
543 p = Project.find(5)
543 p = Project.find(5)
544 v = Version.create!(:name => 'descendants_sharing', :project => p, :sharing => 'descendants')
544 v = Version.create!(:name => 'descendants_sharing', :project => p, :sharing => 'descendants')
545 assert p.shared_versions.include?(v)
545 assert p.shared_versions.include?(v)
546 assert p.children.first.shared_versions.include?(v)
546 assert p.children.first.shared_versions.include?(v)
547 assert !p.root.shared_versions.include?(v)
547 assert !p.root.shared_versions.include?(v)
548 assert !p.siblings.first.shared_versions.include?(v)
548 assert !p.siblings.first.shared_versions.include?(v)
549 assert !p.root.siblings.first.shared_versions.include?(v)
549 assert !p.root.siblings.first.shared_versions.include?(v)
550 end
550 end
551
551
552 def test_shared_versions_hierarchy_sharing
552 def test_shared_versions_hierarchy_sharing
553 p = Project.find(5)
553 p = Project.find(5)
554 v = Version.create!(:name => 'hierarchy_sharing', :project => p, :sharing => 'hierarchy')
554 v = Version.create!(:name => 'hierarchy_sharing', :project => p, :sharing => 'hierarchy')
555 assert p.shared_versions.include?(v)
555 assert p.shared_versions.include?(v)
556 assert p.children.first.shared_versions.include?(v)
556 assert p.children.first.shared_versions.include?(v)
557 assert p.root.shared_versions.include?(v)
557 assert p.root.shared_versions.include?(v)
558 assert !p.siblings.first.shared_versions.include?(v)
558 assert !p.siblings.first.shared_versions.include?(v)
559 assert !p.root.siblings.first.shared_versions.include?(v)
559 assert !p.root.siblings.first.shared_versions.include?(v)
560 end
560 end
561
561
562 def test_shared_versions_tree_sharing
562 def test_shared_versions_tree_sharing
563 p = Project.find(5)
563 p = Project.find(5)
564 v = Version.create!(:name => 'tree_sharing', :project => p, :sharing => 'tree')
564 v = Version.create!(:name => 'tree_sharing', :project => p, :sharing => 'tree')
565 assert p.shared_versions.include?(v)
565 assert p.shared_versions.include?(v)
566 assert p.children.first.shared_versions.include?(v)
566 assert p.children.first.shared_versions.include?(v)
567 assert p.root.shared_versions.include?(v)
567 assert p.root.shared_versions.include?(v)
568 assert p.siblings.first.shared_versions.include?(v)
568 assert p.siblings.first.shared_versions.include?(v)
569 assert !p.root.siblings.first.shared_versions.include?(v)
569 assert !p.root.siblings.first.shared_versions.include?(v)
570 end
570 end
571
571
572 def test_shared_versions_system_sharing
572 def test_shared_versions_system_sharing
573 p = Project.find(5)
573 p = Project.find(5)
574 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
574 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
575 assert p.shared_versions.include?(v)
575 assert p.shared_versions.include?(v)
576 assert p.children.first.shared_versions.include?(v)
576 assert p.children.first.shared_versions.include?(v)
577 assert p.root.shared_versions.include?(v)
577 assert p.root.shared_versions.include?(v)
578 assert p.siblings.first.shared_versions.include?(v)
578 assert p.siblings.first.shared_versions.include?(v)
579 assert p.root.siblings.first.shared_versions.include?(v)
579 assert p.root.siblings.first.shared_versions.include?(v)
580 end
580 end
581
581
582 def test_shared_versions
582 def test_shared_versions
583 parent = Project.find(1)
583 parent = Project.find(1)
584 child = parent.children.find(3)
584 child = parent.children.find(3)
585 private_child = parent.children.find(5)
585 private_child = parent.children.find(5)
586
586
587 assert_equal [1,2,3], parent.version_ids.sort
587 assert_equal [1,2,3], parent.version_ids.sort
588 assert_equal [4], child.version_ids
588 assert_equal [4], child.version_ids
589 assert_equal [6], private_child.version_ids
589 assert_equal [6], private_child.version_ids
590 assert_equal [7], Version.where(:sharing => 'system').collect(&:id)
590 assert_equal [7], Version.where(:sharing => 'system').collect(&:id)
591
591
592 assert_equal 6, parent.shared_versions.size
592 assert_equal 6, parent.shared_versions.size
593 parent.shared_versions.each do |version|
593 parent.shared_versions.each do |version|
594 assert_kind_of Version, version
594 assert_kind_of Version, version
595 end
595 end
596
596
597 assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort
597 assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort
598 end
598 end
599
599
600 def test_shared_versions_should_ignore_archived_subprojects
600 def test_shared_versions_should_ignore_archived_subprojects
601 parent = Project.find(1)
601 parent = Project.find(1)
602 child = parent.children.find(3)
602 child = parent.children.find(3)
603 child.archive
603 child.archive
604 parent.reload
604 parent.reload
605
605
606 assert_equal [1,2,3], parent.version_ids.sort
606 assert_equal [1,2,3], parent.version_ids.sort
607 assert_equal [4], child.version_ids
607 assert_equal [4], child.version_ids
608 assert !parent.shared_versions.collect(&:id).include?(4)
608 assert !parent.shared_versions.collect(&:id).include?(4)
609 end
609 end
610
610
611 def test_shared_versions_visible_to_user
611 def test_shared_versions_visible_to_user
612 user = User.find(3)
612 user = User.find(3)
613 parent = Project.find(1)
613 parent = Project.find(1)
614 child = parent.children.find(5)
614 child = parent.children.find(5)
615
615
616 assert_equal [1,2,3], parent.version_ids.sort
616 assert_equal [1,2,3], parent.version_ids.sort
617 assert_equal [6], child.version_ids
617 assert_equal [6], child.version_ids
618
618
619 versions = parent.shared_versions.visible(user)
619 versions = parent.shared_versions.visible(user)
620
620
621 assert_equal 4, versions.size
621 assert_equal 4, versions.size
622 versions.each do |version|
622 versions.each do |version|
623 assert_kind_of Version, version
623 assert_kind_of Version, version
624 end
624 end
625
625
626 assert !versions.collect(&:id).include?(6)
626 assert !versions.collect(&:id).include?(6)
627 end
627 end
628
628
629 def test_shared_versions_for_new_project_should_include_system_shared_versions
629 def test_shared_versions_for_new_project_should_include_system_shared_versions
630 p = Project.find(5)
630 p = Project.find(5)
631 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
631 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
632
632
633 assert_include v, Project.new.shared_versions
633 assert_include v, Project.new.shared_versions
634 end
634 end
635
635
636 def test_next_identifier
636 def test_next_identifier
637 ProjectCustomField.delete_all
637 ProjectCustomField.delete_all
638 Project.create!(:name => 'last', :identifier => 'p2008040')
638 Project.create!(:name => 'last', :identifier => 'p2008040')
639 assert_equal 'p2008041', Project.next_identifier
639 assert_equal 'p2008041', Project.next_identifier
640 end
640 end
641
641
642 def test_next_identifier_first_project
642 def test_next_identifier_first_project
643 Project.delete_all
643 Project.delete_all
644 assert_nil Project.next_identifier
644 assert_nil Project.next_identifier
645 end
645 end
646
646
647 def test_enabled_module_names
647 def test_enabled_module_names
648 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
648 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
649 project = Project.new
649 project = Project.new
650
650
651 project.enabled_module_names = %w(issue_tracking news)
651 project.enabled_module_names = %w(issue_tracking news)
652 assert_equal %w(issue_tracking news), project.enabled_module_names.sort
652 assert_equal %w(issue_tracking news), project.enabled_module_names.sort
653 end
653 end
654 end
654 end
655
655
656 def test_enabled_modules_names_with_nil_should_clear_modules
656 def test_enabled_modules_names_with_nil_should_clear_modules
657 p = Project.find(1)
657 p = Project.find(1)
658 p.enabled_module_names = nil
658 p.enabled_module_names = nil
659 assert_equal [], p.enabled_modules
659 assert_equal [], p.enabled_modules
660 end
660 end
661
661
662 test "enabled_modules should define module by names and preserve ids" do
662 test "enabled_modules should define module by names and preserve ids" do
663 @project = Project.find(1)
663 @project = Project.find(1)
664 # Remove one module
664 # Remove one module
665 modules = @project.enabled_modules.slice(0..-2)
665 modules = @project.enabled_modules.slice(0..-2)
666 assert modules.any?
666 assert modules.any?
667 assert_difference 'EnabledModule.count', -1 do
667 assert_difference 'EnabledModule.count', -1 do
668 @project.enabled_module_names = modules.collect(&:name)
668 @project.enabled_module_names = modules.collect(&:name)
669 end
669 end
670 @project.reload
670 @project.reload
671 # Ids should be preserved
671 # Ids should be preserved
672 assert_equal @project.enabled_module_ids.sort, modules.collect(&:id).sort
672 assert_equal @project.enabled_module_ids.sort, modules.collect(&:id).sort
673 end
673 end
674
674
675 test "enabled_modules should enable a module" do
675 test "enabled_modules should enable a module" do
676 @project = Project.find(1)
676 @project = Project.find(1)
677 @project.enabled_module_names = []
677 @project.enabled_module_names = []
678 @project.reload
678 @project.reload
679 assert_equal [], @project.enabled_module_names
679 assert_equal [], @project.enabled_module_names
680 #with string
680 #with string
681 @project.enable_module!("issue_tracking")
681 @project.enable_module!("issue_tracking")
682 assert_equal ["issue_tracking"], @project.enabled_module_names
682 assert_equal ["issue_tracking"], @project.enabled_module_names
683 #with symbol
683 #with symbol
684 @project.enable_module!(:gantt)
684 @project.enable_module!(:gantt)
685 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
685 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
686 #don't add a module twice
686 #don't add a module twice
687 @project.enable_module!("issue_tracking")
687 @project.enable_module!("issue_tracking")
688 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
688 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
689 end
689 end
690
690
691 test "enabled_modules should disable a module" do
691 test "enabled_modules should disable a module" do
692 @project = Project.find(1)
692 @project = Project.find(1)
693 #with string
693 #with string
694 assert @project.enabled_module_names.include?("issue_tracking")
694 assert @project.enabled_module_names.include?("issue_tracking")
695 @project.disable_module!("issue_tracking")
695 @project.disable_module!("issue_tracking")
696 assert ! @project.reload.enabled_module_names.include?("issue_tracking")
696 assert ! @project.reload.enabled_module_names.include?("issue_tracking")
697 #with symbol
697 #with symbol
698 assert @project.enabled_module_names.include?("gantt")
698 assert @project.enabled_module_names.include?("gantt")
699 @project.disable_module!(:gantt)
699 @project.disable_module!(:gantt)
700 assert ! @project.reload.enabled_module_names.include?("gantt")
700 assert ! @project.reload.enabled_module_names.include?("gantt")
701 #with EnabledModule object
701 #with EnabledModule object
702 first_module = @project.enabled_modules.first
702 first_module = @project.enabled_modules.first
703 @project.disable_module!(first_module)
703 @project.disable_module!(first_module)
704 assert ! @project.reload.enabled_module_names.include?(first_module.name)
704 assert ! @project.reload.enabled_module_names.include?(first_module.name)
705 end
705 end
706
706
707 def test_enabled_module_names_should_not_recreate_enabled_modules
707 def test_enabled_module_names_should_not_recreate_enabled_modules
708 project = Project.find(1)
708 project = Project.find(1)
709 # Remove one module
709 # Remove one module
710 modules = project.enabled_modules.slice(0..-2)
710 modules = project.enabled_modules.slice(0..-2)
711 assert modules.any?
711 assert modules.any?
712 assert_difference 'EnabledModule.count', -1 do
712 assert_difference 'EnabledModule.count', -1 do
713 project.enabled_module_names = modules.collect(&:name)
713 project.enabled_module_names = modules.collect(&:name)
714 end
714 end
715 project.reload
715 project.reload
716 # Ids should be preserved
716 # Ids should be preserved
717 assert_equal project.enabled_module_ids.sort, modules.collect(&:id).sort
717 assert_equal project.enabled_module_ids.sort, modules.collect(&:id).sort
718 end
718 end
719
719
720 def test_copy_from_existing_project
720 def test_copy_from_existing_project
721 source_project = Project.find(1)
721 source_project = Project.find(1)
722 copied_project = Project.copy_from(1)
722 copied_project = Project.copy_from(1)
723
723
724 assert copied_project
724 assert copied_project
725 # Cleared attributes
725 # Cleared attributes
726 assert copied_project.id.blank?
726 assert copied_project.id.blank?
727 assert copied_project.name.blank?
727 assert copied_project.name.blank?
728 assert copied_project.identifier.blank?
728 assert copied_project.identifier.blank?
729
729
730 # Duplicated attributes
730 # Duplicated attributes
731 assert_equal source_project.description, copied_project.description
731 assert_equal source_project.description, copied_project.description
732 assert_equal source_project.trackers, copied_project.trackers
732 assert_equal source_project.trackers, copied_project.trackers
733
733
734 # Default attributes
734 # Default attributes
735 assert_equal 1, copied_project.status
735 assert_equal 1, copied_project.status
736 end
736 end
737
737
738 def test_copy_from_should_copy_enabled_modules
738 def test_copy_from_should_copy_enabled_modules
739 source = Project.generate!
739 source = Project.generate!
740 source.enabled_module_names = %w(issue_tracking wiki)
740 source.enabled_module_names = %w(issue_tracking wiki)
741
741
742 copy = Project.copy_from(source)
742 copy = Project.copy_from(source)
743 copy.name = 'Copy'
743 copy.name = 'Copy'
744 copy.identifier = 'copy'
744 copy.identifier = 'copy'
745 assert_difference 'EnabledModule.count', 2 do
745 assert_difference 'EnabledModule.count', 2 do
746 copy.save!
746 copy.save!
747 end
747 end
748 assert_equal 2, copy.reload.enabled_modules.count
748 assert_equal 2, copy.reload.enabled_modules.count
749 assert_equal 2, source.reload.enabled_modules.count
749 assert_equal 2, source.reload.enabled_modules.count
750 end
750 end
751
751
752 def test_activities_should_use_the_system_activities
752 def test_activities_should_use_the_system_activities
753 project = Project.find(1)
753 project = Project.find(1)
754 assert_equal project.activities.to_a, TimeEntryActivity.where(:active => true).to_a
754 assert_equal project.activities.to_a, TimeEntryActivity.where(:active => true).to_a
755 assert_kind_of ActiveRecord::Relation, project.activities
755 assert_kind_of ActiveRecord::Relation, project.activities
756 end
756 end
757
757
758
758
759 def test_activities_should_use_the_project_specific_activities
759 def test_activities_should_use_the_project_specific_activities
760 project = Project.find(1)
760 project = Project.find(1)
761 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project})
761 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project})
762 assert overridden_activity.save!
762 assert overridden_activity.save!
763
763
764 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
764 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
765 assert_kind_of ActiveRecord::Relation, project.activities
765 assert_kind_of ActiveRecord::Relation, project.activities
766 end
766 end
767
767
768 def test_activities_should_not_include_the_inactive_project_specific_activities
768 def test_activities_should_not_include_the_inactive_project_specific_activities
769 project = Project.find(1)
769 project = Project.find(1)
770 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.first, :active => false})
770 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.first, :active => false})
771 assert overridden_activity.save!
771 assert overridden_activity.save!
772
772
773 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity found"
773 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity found"
774 end
774 end
775
775
776 def test_activities_should_not_include_project_specific_activities_from_other_projects
776 def test_activities_should_not_include_project_specific_activities_from_other_projects
777 project = Project.find(1)
777 project = Project.find(1)
778 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(2)})
778 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(2)})
779 assert overridden_activity.save!
779 assert overridden_activity.save!
780
780
781 assert !project.activities.include?(overridden_activity), "Project specific Activity found on a different project"
781 assert !project.activities.include?(overridden_activity), "Project specific Activity found on a different project"
782 end
782 end
783
783
784 def test_activities_should_handle_nils
784 def test_activities_should_handle_nils
785 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.first})
785 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.first})
786 TimeEntryActivity.delete_all
786 TimeEntryActivity.delete_all
787
787
788 # No activities
788 # No activities
789 project = Project.find(1)
789 project = Project.find(1)
790 assert project.activities.empty?
790 assert project.activities.empty?
791
791
792 # No system, one overridden
792 # No system, one overridden
793 assert overridden_activity.save!
793 assert overridden_activity.save!
794 project.reload
794 project.reload
795 assert_equal [overridden_activity], project.activities
795 assert_equal [overridden_activity], project.activities
796 end
796 end
797
797
798 def test_activities_should_override_system_activities_with_project_activities
798 def test_activities_should_override_system_activities_with_project_activities
799 project = Project.find(1)
799 project = Project.find(1)
800 parent_activity = TimeEntryActivity.first
800 parent_activity = TimeEntryActivity.first
801 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => parent_activity})
801 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => parent_activity})
802 assert overridden_activity.save!
802 assert overridden_activity.save!
803
803
804 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
804 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
805 assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
805 assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
806 end
806 end
807
807
808 def test_activities_should_include_inactive_activities_if_specified
808 def test_activities_should_include_inactive_activities_if_specified
809 project = Project.find(1)
809 project = Project.find(1)
810 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.first, :active => false})
810 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.first, :active => false})
811 assert overridden_activity.save!
811 assert overridden_activity.save!
812
812
813 assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
813 assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
814 end
814 end
815
815
816 test 'activities should not include active System activities if the project has an override that is inactive' do
816 test 'activities should not include active System activities if the project has an override that is inactive' do
817 project = Project.find(1)
817 project = Project.find(1)
818 system_activity = TimeEntryActivity.find_by_name('Design')
818 system_activity = TimeEntryActivity.find_by_name('Design')
819 assert system_activity.active?
819 assert system_activity.active?
820 overridden_activity = TimeEntryActivity.create!(:name => "Project", :project => project, :parent => system_activity, :active => false)
820 overridden_activity = TimeEntryActivity.create!(:name => "Project", :project => project, :parent => system_activity, :active => false)
821 assert overridden_activity.save!
821 assert overridden_activity.save!
822
822
823 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity not found"
823 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity not found"
824 assert !project.activities.include?(system_activity), "System activity found when the project has an inactive override"
824 assert !project.activities.include?(system_activity), "System activity found when the project has an inactive override"
825 end
825 end
826
826
827 def test_close_completed_versions
827 def test_close_completed_versions
828 Version.update_all("status = 'open'")
828 Version.update_all("status = 'open'")
829 project = Project.find(1)
829 project = Project.find(1)
830 assert_not_nil project.versions.detect {|v| v.completed? && v.status == 'open'}
830 assert_not_nil project.versions.detect {|v| v.completed? && v.status == 'open'}
831 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
831 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
832 project.close_completed_versions
832 project.close_completed_versions
833 project.reload
833 project.reload
834 assert_nil project.versions.detect {|v| v.completed? && v.status != 'closed'}
834 assert_nil project.versions.detect {|v| v.completed? && v.status != 'closed'}
835 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
835 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
836 end
836 end
837
837
838 test "#start_date should be nil if there are no issues on the project" do
838 test "#start_date should be nil if there are no issues on the project" do
839 project = Project.generate!
839 project = Project.generate!
840 assert_nil project.start_date
840 assert_nil project.start_date
841 end
841 end
842
842
843 test "#start_date should be nil when issues have no start date" do
843 test "#start_date should be nil when issues have no start date" do
844 project = Project.generate!
844 project = Project.generate!
845 project.trackers << Tracker.generate!
845 project.trackers << Tracker.generate!
846 early = 7.days.ago.to_date
846 early = 7.days.ago.to_date
847 Issue.generate!(:project => project, :start_date => nil)
847 Issue.generate!(:project => project, :start_date => nil)
848
848
849 assert_nil project.start_date
849 assert_nil project.start_date
850 end
850 end
851
851
852 test "#start_date should be the earliest start date of it's issues" do
852 test "#start_date should be the earliest start date of it's issues" do
853 project = Project.generate!
853 project = Project.generate!
854 project.trackers << Tracker.generate!
854 project.trackers << Tracker.generate!
855 early = 7.days.ago.to_date
855 early = 7.days.ago.to_date
856 Issue.generate!(:project => project, :start_date => Date.today)
856 Issue.generate!(:project => project, :start_date => Date.today)
857 Issue.generate!(:project => project, :start_date => early)
857 Issue.generate!(:project => project, :start_date => early)
858
858
859 assert_equal early, project.start_date
859 assert_equal early, project.start_date
860 end
860 end
861
861
862 test "#due_date should be nil if there are no issues on the project" do
862 test "#due_date should be nil if there are no issues on the project" do
863 project = Project.generate!
863 project = Project.generate!
864 assert_nil project.due_date
864 assert_nil project.due_date
865 end
865 end
866
866
867 test "#due_date should be nil if there are no issues with due dates" do
867 test "#due_date should be nil if there are no issues with due dates" do
868 project = Project.generate!
868 project = Project.generate!
869 project.trackers << Tracker.generate!
869 project.trackers << Tracker.generate!
870 Issue.generate!(:project => project, :due_date => nil)
870 Issue.generate!(:project => project, :due_date => nil)
871
871
872 assert_nil project.due_date
872 assert_nil project.due_date
873 end
873 end
874
874
875 test "#due_date should be the latest due date of it's issues" do
875 test "#due_date should be the latest due date of it's issues" do
876 project = Project.generate!
876 project = Project.generate!
877 project.trackers << Tracker.generate!
877 project.trackers << Tracker.generate!
878 future = 7.days.from_now.to_date
878 future = 7.days.from_now.to_date
879 Issue.generate!(:project => project, :due_date => future)
879 Issue.generate!(:project => project, :due_date => future)
880 Issue.generate!(:project => project, :due_date => Date.today)
880 Issue.generate!(:project => project, :due_date => Date.today)
881
881
882 assert_equal future, project.due_date
882 assert_equal future, project.due_date
883 end
883 end
884
884
885 test "#due_date should be the latest due date of it's versions" do
885 test "#due_date should be the latest due date of it's versions" do
886 project = Project.generate!
886 project = Project.generate!
887 future = 7.days.from_now.to_date
887 future = 7.days.from_now.to_date
888 project.versions << Version.generate!(:effective_date => future)
888 project.versions << Version.generate!(:effective_date => future)
889 project.versions << Version.generate!(:effective_date => Date.today)
889 project.versions << Version.generate!(:effective_date => Date.today)
890
890
891 assert_equal future, project.due_date
891 assert_equal future, project.due_date
892 end
892 end
893
893
894 test "#due_date should pick the latest date from it's issues and versions" do
894 test "#due_date should pick the latest date from it's issues and versions" do
895 project = Project.generate!
895 project = Project.generate!
896 project.trackers << Tracker.generate!
896 project.trackers << Tracker.generate!
897 future = 7.days.from_now.to_date
897 future = 7.days.from_now.to_date
898 far_future = 14.days.from_now.to_date
898 far_future = 14.days.from_now.to_date
899 Issue.generate!(:project => project, :due_date => far_future)
899 Issue.generate!(:project => project, :due_date => far_future)
900 project.versions << Version.generate!(:effective_date => future)
900 project.versions << Version.generate!(:effective_date => future)
901
901
902 assert_equal far_future, project.due_date
902 assert_equal far_future, project.due_date
903 end
903 end
904
904
905 test "#completed_percent with no versions should be 100" do
905 test "#completed_percent with no versions should be 100" do
906 project = Project.generate!
906 project = Project.generate!
907 assert_equal 100, project.completed_percent
907 assert_equal 100, project.completed_percent
908 end
908 end
909
909
910 test "#completed_percent with versions should return 0 if the versions have no issues" do
910 test "#completed_percent with versions should return 0 if the versions have no issues" do
911 project = Project.generate!
911 project = Project.generate!
912 Version.generate!(:project => project)
912 Version.generate!(:project => project)
913 Version.generate!(:project => project)
913 Version.generate!(:project => project)
914
914
915 assert_equal 0, project.completed_percent
915 assert_equal 0, project.completed_percent
916 end
916 end
917
917
918 test "#completed_percent with versions should return 100 if the version has only closed issues" do
918 test "#completed_percent with versions should return 100 if the version has only closed issues" do
919 project = Project.generate!
919 project = Project.generate!
920 project.trackers << Tracker.generate!
920 project.trackers << Tracker.generate!
921 v1 = Version.generate!(:project => project)
921 v1 = Version.generate!(:project => project)
922 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v1)
922 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v1)
923 v2 = Version.generate!(:project => project)
923 v2 = Version.generate!(:project => project)
924 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v2)
924 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v2)
925
925
926 assert_equal 100, project.completed_percent
926 assert_equal 100, project.completed_percent
927 end
927 end
928
928
929 test "#completed_percent with versions should return the averaged completed percent of the versions (not weighted)" do
929 test "#completed_percent with versions should return the averaged completed percent of the versions (not weighted)" do
930 project = Project.generate!
930 project = Project.generate!
931 project.trackers << Tracker.generate!
931 project.trackers << Tracker.generate!
932 v1 = Version.generate!(:project => project)
932 v1 = Version.generate!(:project => project)
933 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v1)
933 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v1)
934 v2 = Version.generate!(:project => project)
934 v2 = Version.generate!(:project => project)
935 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v2)
935 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v2)
936
936
937 assert_equal 50, project.completed_percent
937 assert_equal 50, project.completed_percent
938 end
938 end
939
939
940 test "#notified_users" do
940 test "#notified_users" do
941 project = Project.generate!
941 project = Project.generate!
942 role = Role.generate!
942 role = Role.generate!
943
943
944 user_with_membership_notification = User.generate!(:mail_notification => 'selected')
944 user_with_membership_notification = User.generate!(:mail_notification => 'selected')
945 Member.create!(:project => project, :roles => [role], :principal => user_with_membership_notification, :mail_notification => true)
945 Member.create!(:project => project, :roles => [role], :principal => user_with_membership_notification, :mail_notification => true)
946
946
947 all_events_user = User.generate!(:mail_notification => 'all')
947 all_events_user = User.generate!(:mail_notification => 'all')
948 Member.create!(:project => project, :roles => [role], :principal => all_events_user)
948 Member.create!(:project => project, :roles => [role], :principal => all_events_user)
949
949
950 no_events_user = User.generate!(:mail_notification => 'none')
950 no_events_user = User.generate!(:mail_notification => 'none')
951 Member.create!(:project => project, :roles => [role], :principal => no_events_user)
951 Member.create!(:project => project, :roles => [role], :principal => no_events_user)
952
952
953 only_my_events_user = User.generate!(:mail_notification => 'only_my_events')
953 only_my_events_user = User.generate!(:mail_notification => 'only_my_events')
954 Member.create!(:project => project, :roles => [role], :principal => only_my_events_user)
954 Member.create!(:project => project, :roles => [role], :principal => only_my_events_user)
955
955
956 only_assigned_user = User.generate!(:mail_notification => 'only_assigned')
956 only_assigned_user = User.generate!(:mail_notification => 'only_assigned')
957 Member.create!(:project => project, :roles => [role], :principal => only_assigned_user)
957 Member.create!(:project => project, :roles => [role], :principal => only_assigned_user)
958
958
959 only_owned_user = User.generate!(:mail_notification => 'only_owner')
959 only_owned_user = User.generate!(:mail_notification => 'only_owner')
960 Member.create!(:project => project, :roles => [role], :principal => only_owned_user)
960 Member.create!(:project => project, :roles => [role], :principal => only_owned_user)
961
961
962 assert project.notified_users.include?(user_with_membership_notification), "should include members with a mail notification"
962 assert project.notified_users.include?(user_with_membership_notification), "should include members with a mail notification"
963 assert project.notified_users.include?(all_events_user), "should include users with the 'all' notification option"
963 assert project.notified_users.include?(all_events_user), "should include users with the 'all' notification option"
964 assert !project.notified_users.include?(no_events_user), "should not include users with the 'none' notification option"
964 assert !project.notified_users.include?(no_events_user), "should not include users with the 'none' notification option"
965 assert !project.notified_users.include?(only_my_events_user), "should not include users with the 'only_my_events' notification option"
965 assert !project.notified_users.include?(only_my_events_user), "should not include users with the 'only_my_events' notification option"
966 assert !project.notified_users.include?(only_assigned_user), "should not include users with the 'only_assigned' notification option"
966 assert !project.notified_users.include?(only_assigned_user), "should not include users with the 'only_assigned' notification option"
967 assert !project.notified_users.include?(only_owned_user), "should not include users with the 'only_owner' notification option"
967 assert !project.notified_users.include?(only_owned_user), "should not include users with the 'only_owner' notification option"
968 end
968 end
969
969
970 def test_override_roles_without_builtin_group_memberships
970 def test_override_roles_without_builtin_group_memberships
971 project = Project.generate!
971 project = Project.generate!
972 assert_equal [Role.anonymous], project.override_roles(Role.anonymous)
972 assert_equal [Role.anonymous], project.override_roles(Role.anonymous)
973 assert_equal [Role.non_member], project.override_roles(Role.non_member)
973 assert_equal [Role.non_member], project.override_roles(Role.non_member)
974 end
974 end
975
975
976 def test_css_classes
976 def test_css_classes
977 p = Project.new
977 p = Project.new
978 assert_kind_of String, p.css_classes
978 assert_kind_of String, p.css_classes
979 assert_not_include 'archived', p.css_classes.split
979 assert_not_include 'archived', p.css_classes.split
980 assert_not_include 'closed', p.css_classes.split
980 assert_not_include 'closed', p.css_classes.split
981 end
981 end
982
982
983 def test_css_classes_for_archived_project
983 def test_css_classes_for_archived_project
984 p = Project.new
984 p = Project.new
985 p.status = Project::STATUS_ARCHIVED
985 p.status = Project::STATUS_ARCHIVED
986 assert_include 'archived', p.css_classes.split
986 assert_include 'archived', p.css_classes.split
987 end
987 end
988
988
989 def test_css_classes_for_closed_project
989 def test_css_classes_for_closed_project
990 p = Project.new
990 p = Project.new
991 p.status = Project::STATUS_CLOSED
991 p.status = Project::STATUS_CLOSED
992 assert_include 'closed', p.css_classes.split
992 assert_include 'closed', p.css_classes.split
993 end
993 end
994
994
995 def test_combination_of_visible_and_uniq_scopes_in_case_anonymous_group_has_memberships_should_not_error
995 def test_combination_of_visible_and_distinct_scopes_in_case_anonymous_group_has_memberships_should_not_error
996 project = Project.find(1)
996 project = Project.find(1)
997 member = Member.create!(:project => project, :principal => Group.anonymous, :roles => [Role.generate!])
997 member = Member.create!(:project => project, :principal => Group.anonymous, :roles => [Role.generate!])
998 project.members << member
998 project.members << member
999 assert_nothing_raised do
999 assert_nothing_raised do
1000 Project.uniq.visible.to_a
1000 Project.distinct.visible.to_a
1001 end
1001 end
1002 end
1002 end
1003 end
1003 end
General Comments 0
You need to be logged in to leave comments. Login now