##// END OF EJS Templates
Use .distinct instead of .uniq....
Jean-Philippe Lang -
r15272:d2f7e31951d0
parent child
Show More
@@ -1,183 +1,183
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class VersionsController < ApplicationController
19 19 menu_item :roadmap
20 20 model_object Version
21 21 before_filter :find_model_object, :except => [:index, :new, :create, :close_completed]
22 22 before_filter :find_project_from_association, :except => [:index, :new, :create, :close_completed]
23 23 before_filter :find_project_by_project_id, :only => [:index, :new, :create, :close_completed]
24 24 before_filter :authorize
25 25
26 26 accept_api_auth :index, :show, :create, :update, :destroy
27 27
28 28 helper :custom_fields
29 29 helper :projects
30 30
31 31 def index
32 32 respond_to do |format|
33 33 format.html {
34 34 @trackers = @project.trackers.sorted.to_a
35 35 retrieve_selected_tracker_ids(@trackers, @trackers.select {|t| t.is_in_roadmap?})
36 36 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
37 37 project_ids = @with_subprojects ? @project.self_and_descendants.collect(&:id) : [@project.id]
38 38
39 39 @versions = @project.shared_versions.preload(:custom_values)
40 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 42 unless params[:completed]
43 43 @completed_versions = @versions.select(&:completed?)
44 44 @versions -= @completed_versions
45 45 end
46 46
47 47 @issues_by_version = {}
48 48 if @selected_tracker_ids.any? && @versions.any?
49 49 issues = Issue.visible.
50 50 includes(:project, :tracker).
51 51 preload(:status, :priority, :fixed_version).
52 52 where(:tracker_id => @selected_tracker_ids, :project_id => project_ids, :fixed_version_id => @versions.map(&:id)).
53 53 order("#{Project.table_name}.lft, #{Tracker.table_name}.position, #{Issue.table_name}.id")
54 54 @issues_by_version = issues.group_by(&:fixed_version)
55 55 end
56 56 @versions.reject! {|version| !project_ids.include?(version.project_id) && @issues_by_version[version].blank?}
57 57 }
58 58 format.api {
59 59 @versions = @project.shared_versions.to_a
60 60 }
61 61 end
62 62 end
63 63
64 64 def show
65 65 respond_to do |format|
66 66 format.html {
67 67 @issues = @version.fixed_issues.visible.
68 68 includes(:status, :tracker, :priority).
69 69 preload(:project).
70 70 reorder("#{Tracker.table_name}.position, #{Issue.table_name}.id").
71 71 to_a
72 72 }
73 73 format.api
74 74 end
75 75 end
76 76
77 77 def new
78 78 @version = @project.versions.build
79 79 @version.safe_attributes = params[:version]
80 80
81 81 respond_to do |format|
82 82 format.html
83 83 format.js
84 84 end
85 85 end
86 86
87 87 def create
88 88 @version = @project.versions.build
89 89 if params[:version]
90 90 attributes = params[:version].dup
91 91 attributes.delete('sharing') unless attributes.nil? || @version.allowed_sharings.include?(attributes['sharing'])
92 92 @version.safe_attributes = attributes
93 93 end
94 94
95 95 if request.post?
96 96 if @version.save
97 97 respond_to do |format|
98 98 format.html do
99 99 flash[:notice] = l(:notice_successful_create)
100 100 redirect_back_or_default settings_project_path(@project, :tab => 'versions')
101 101 end
102 102 format.js
103 103 format.api do
104 104 render :action => 'show', :status => :created, :location => version_url(@version)
105 105 end
106 106 end
107 107 else
108 108 respond_to do |format|
109 109 format.html { render :action => 'new' }
110 110 format.js { render :action => 'new' }
111 111 format.api { render_validation_errors(@version) }
112 112 end
113 113 end
114 114 end
115 115 end
116 116
117 117 def edit
118 118 end
119 119
120 120 def update
121 121 if params[:version]
122 122 attributes = params[:version].dup
123 123 attributes.delete('sharing') unless @version.allowed_sharings.include?(attributes['sharing'])
124 124 @version.safe_attributes = attributes
125 125 if @version.save
126 126 respond_to do |format|
127 127 format.html {
128 128 flash[:notice] = l(:notice_successful_update)
129 129 redirect_back_or_default settings_project_path(@project, :tab => 'versions')
130 130 }
131 131 format.api { render_api_ok }
132 132 end
133 133 else
134 134 respond_to do |format|
135 135 format.html { render :action => 'edit' }
136 136 format.api { render_validation_errors(@version) }
137 137 end
138 138 end
139 139 end
140 140 end
141 141
142 142 def close_completed
143 143 if request.put?
144 144 @project.close_completed_versions
145 145 end
146 146 redirect_to settings_project_path(@project, :tab => 'versions')
147 147 end
148 148
149 149 def destroy
150 150 if @version.deletable?
151 151 @version.destroy
152 152 respond_to do |format|
153 153 format.html { redirect_back_or_default settings_project_path(@project, :tab => 'versions') }
154 154 format.api { render_api_ok }
155 155 end
156 156 else
157 157 respond_to do |format|
158 158 format.html {
159 159 flash[:error] = l(:notice_unable_delete_version)
160 160 redirect_to settings_project_path(@project, :tab => 'versions')
161 161 }
162 162 format.api { head :unprocessable_entity }
163 163 end
164 164 end
165 165 end
166 166
167 167 def status_by
168 168 respond_to do |format|
169 169 format.html { render :action => 'show' }
170 170 format.js
171 171 end
172 172 end
173 173
174 174 private
175 175
176 176 def retrieve_selected_tracker_ids(selectable_trackers, default_trackers=nil)
177 177 if ids = params[:tracker_ids]
178 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 179 else
180 180 @selected_tracker_ids = (default_trackers || selectable_trackers).collect {|t| t.id.to_s }
181 181 end
182 182 end
183 183 end
@@ -1,113 +1,113
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueStatus < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 20 has_many :workflows, :class_name => 'WorkflowTransition', :foreign_key => "old_status_id"
21 21 has_many :workflow_transitions_as_new_status, :class_name => 'WorkflowTransition', :foreign_key => "new_status_id"
22 22 acts_as_positioned
23 23
24 24 after_update :handle_is_closed_change
25 25 before_destroy :delete_workflow_rules
26 26
27 27 validates_presence_of :name
28 28 validates_uniqueness_of :name
29 29 validates_length_of :name, :maximum => 30
30 30 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
31 31 attr_protected :id
32 32
33 33 scope :sorted, lambda { order(:position) }
34 34 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
35 35
36 36 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
37 37 def self.update_issue_done_ratios
38 38 if Issue.use_status_for_done_ratio?
39 39 IssueStatus.where("default_done_ratio >= 0").each do |status|
40 40 Issue.where({:status_id => status.id}).update_all({:done_ratio => status.default_done_ratio})
41 41 end
42 42 end
43 43
44 44 return Issue.use_status_for_done_ratio?
45 45 end
46 46
47 47 # Returns an array of all statuses the given role can switch to
48 48 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
49 49 self.class.new_statuses_allowed(self, roles, tracker, author, assignee)
50 50 end
51 51 alias :find_new_statuses_allowed_to :new_statuses_allowed_to
52 52
53 53 def self.new_statuses_allowed(status, roles, tracker, author=false, assignee=false)
54 54 if roles.present? && tracker
55 55 status_id = status.try(:id) || 0
56 56
57 57 scope = IssueStatus.
58 58 joins(:workflow_transitions_as_new_status).
59 59 where(:workflows => {:old_status_id => status_id, :role_id => roles.map(&:id), :tracker_id => tracker.id})
60 60
61 61 unless author && assignee
62 62 if author || assignee
63 63 scope = scope.where("author = ? OR assignee = ?", author, assignee)
64 64 else
65 65 scope = scope.where("author = ? AND assignee = ?", false, false)
66 66 end
67 67 end
68 68
69 scope.uniq.to_a.sort
69 scope.distinct.to_a.sort
70 70 else
71 71 []
72 72 end
73 73 end
74 74
75 75 def <=>(status)
76 76 position <=> status.position
77 77 end
78 78
79 79 def to_s; name end
80 80
81 81 private
82 82
83 83 # Updates issues closed_on attribute when an existing status is set as closed.
84 84 def handle_is_closed_change
85 85 if is_closed_changed? && is_closed == true
86 86 # First we update issues that have a journal for when the current status was set,
87 87 # a subselect is used to update all issues with a single query
88 88 subselect = "SELECT MAX(j.created_on) FROM #{Journal.table_name} j" +
89 89 " JOIN #{JournalDetail.table_name} d ON d.journal_id = j.id" +
90 90 " WHERE j.journalized_type = 'Issue' AND j.journalized_id = #{Issue.table_name}.id" +
91 91 " AND d.property = 'attr' AND d.prop_key = 'status_id' AND d.value = :status_id"
92 92 Issue.where(:status_id => id, :closed_on => nil).
93 93 update_all(["closed_on = (#{subselect})", {:status_id => id.to_s}])
94 94
95 95 # Then we update issues that don't have a journal which means the
96 96 # current status was set on creation
97 97 Issue.where(:status_id => id, :closed_on => nil).update_all("closed_on = created_on")
98 98 end
99 99 end
100 100
101 101 def check_integrity
102 102 if Issue.where(:status_id => id).any?
103 103 raise "This status is used by some issues"
104 104 elsif Tracker.where(:default_status_id => id).any?
105 105 raise "This status is used as the default status by some trackers"
106 106 end
107 107 end
108 108
109 109 # Deletes associated workflows
110 110 def delete_workflow_rules
111 111 WorkflowRule.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
112 112 end
113 113 end
@@ -1,312 +1,312
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Journal < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 belongs_to :journalized, :polymorphic => true
22 22 # added as a quick fix to allow eager loading of the polymorphic association
23 23 # since always associated to an issue, for now
24 24 belongs_to :issue, :foreign_key => :journalized_id
25 25
26 26 belongs_to :user
27 27 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all, :inverse_of => :journal
28 28 attr_accessor :indice
29 29 attr_protected :id
30 30
31 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 32 :description => :notes,
33 33 :author => :user,
34 34 :group => :issue,
35 35 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
36 36 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
37 37
38 38 acts_as_activity_provider :type => 'issues',
39 39 :author_key => :user_id,
40 40 :scope => preload({:issue => :project}, :user).
41 41 joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
42 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 45 before_create :split_private_notes
46 46 after_create :send_notification
47 47
48 48 scope :visible, lambda {|*args|
49 49 user = args.shift || User.current
50 50 joins(:issue => :project).
51 51 where(Issue.visible_condition(user, *args)).
52 52 where("(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes, *args)}))", false)
53 53 }
54 54
55 55 safe_attributes 'notes',
56 56 :if => lambda {|journal, user| journal.new_record? || journal.editable_by?(user)}
57 57 safe_attributes 'private_notes',
58 58 :if => lambda {|journal, user| user.allowed_to?(:set_notes_private, journal.project)}
59 59
60 60 def initialize(*args)
61 61 super
62 62 if journalized
63 63 if journalized.new_record?
64 64 self.notify = false
65 65 else
66 66 start
67 67 end
68 68 end
69 69 end
70 70
71 71 def save(*args)
72 72 journalize_changes
73 73 # Do not save an empty journal
74 74 (details.empty? && notes.blank?) ? false : super
75 75 end
76 76
77 77 # Returns journal details that are visible to user
78 78 def visible_details(user=User.current)
79 79 details.select do |detail|
80 80 if detail.property == 'cf'
81 81 detail.custom_field && detail.custom_field.visible_by?(project, user)
82 82 elsif detail.property == 'relation'
83 83 Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
84 84 else
85 85 true
86 86 end
87 87 end
88 88 end
89 89
90 90 def each_notification(users, &block)
91 91 if users.any?
92 92 users_by_details_visibility = users.group_by do |user|
93 93 visible_details(user)
94 94 end
95 95 users_by_details_visibility.each do |visible_details, users|
96 96 if notes? || visible_details.any?
97 97 yield(users)
98 98 end
99 99 end
100 100 end
101 101 end
102 102
103 103 # Returns the JournalDetail for the given attribute, or nil if the attribute
104 104 # was not updated
105 105 def detail_for_attribute(attribute)
106 106 details.detect {|detail| detail.prop_key == attribute}
107 107 end
108 108
109 109 # Returns the new status if the journal contains a status change, otherwise nil
110 110 def new_status
111 111 s = new_value_for('status_id')
112 112 s ? IssueStatus.find_by_id(s.to_i) : nil
113 113 end
114 114
115 115 def new_value_for(prop)
116 116 detail_for_attribute(prop).try(:value)
117 117 end
118 118
119 119 def editable_by?(usr)
120 120 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
121 121 end
122 122
123 123 def project
124 124 journalized.respond_to?(:project) ? journalized.project : nil
125 125 end
126 126
127 127 def attachments
128 128 journalized.respond_to?(:attachments) ? journalized.attachments : nil
129 129 end
130 130
131 131 # Returns a string of css classes
132 132 def css_classes
133 133 s = 'journal'
134 134 s << ' has-notes' unless notes.blank?
135 135 s << ' has-details' unless details.blank?
136 136 s << ' private-notes' if private_notes?
137 137 s
138 138 end
139 139
140 140 def notify?
141 141 @notify != false
142 142 end
143 143
144 144 def notify=(arg)
145 145 @notify = arg
146 146 end
147 147
148 148 def notified_users
149 149 notified = journalized.notified_users
150 150 if private_notes?
151 151 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
152 152 end
153 153 notified
154 154 end
155 155
156 156 def recipients
157 157 notified_users.map(&:mail)
158 158 end
159 159
160 160 def notified_watchers
161 161 notified = journalized.notified_watchers
162 162 if private_notes?
163 163 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
164 164 end
165 165 notified
166 166 end
167 167
168 168 def watcher_recipients
169 169 notified_watchers.map(&:mail)
170 170 end
171 171
172 172 # Sets @custom_field instance variable on journals details using a single query
173 173 def self.preload_journals_details_custom_fields(journals)
174 174 field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
175 175 if field_ids.any?
176 176 fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
177 177 journals.each do |journal|
178 178 journal.details.each do |detail|
179 179 if detail.property == 'cf'
180 180 detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
181 181 end
182 182 end
183 183 end
184 184 end
185 185 journals
186 186 end
187 187
188 188 # Stores the values of the attributes and custom fields of the journalized object
189 189 def start
190 190 if journalized
191 191 @attributes_before_change = journalized.journalized_attribute_names.inject({}) do |h, attribute|
192 192 h[attribute] = journalized.send(attribute)
193 193 h
194 194 end
195 195 @custom_values_before_change = journalized.custom_field_values.inject({}) do |h, c|
196 196 h[c.custom_field_id] = c.value
197 197 h
198 198 end
199 199 end
200 200 self
201 201 end
202 202
203 203 # Adds a journal detail for an attachment that was added or removed
204 204 def journalize_attachment(attachment, added_or_removed)
205 205 key = (added_or_removed == :removed ? :old_value : :value)
206 206 details << JournalDetail.new(
207 207 :property => 'attachment',
208 208 :prop_key => attachment.id,
209 209 key => attachment.filename
210 210 )
211 211 end
212 212
213 213 # Adds a journal detail for an issue relation that was added or removed
214 214 def journalize_relation(relation, added_or_removed)
215 215 key = (added_or_removed == :removed ? :old_value : :value)
216 216 details << JournalDetail.new(
217 217 :property => 'relation',
218 218 :prop_key => relation.relation_type_for(journalized),
219 219 key => relation.other_issue(journalized).try(:id)
220 220 )
221 221 end
222 222
223 223 private
224 224
225 225 # Generates journal details for attribute and custom field changes
226 226 def journalize_changes
227 227 # attributes changes
228 228 if @attributes_before_change
229 229 journalized.journalized_attribute_names.each {|attribute|
230 230 before = @attributes_before_change[attribute]
231 231 after = journalized.send(attribute)
232 232 next if before == after || (before.blank? && after.blank?)
233 233 add_attribute_detail(attribute, before, after)
234 234 }
235 235 end
236 236 if @custom_values_before_change
237 237 # custom fields changes
238 238 journalized.custom_field_values.each {|c|
239 239 before = @custom_values_before_change[c.custom_field_id]
240 240 after = c.value
241 241 next if before == after || (before.blank? && after.blank?)
242 242
243 243 if before.is_a?(Array) || after.is_a?(Array)
244 244 before = [before] unless before.is_a?(Array)
245 245 after = [after] unless after.is_a?(Array)
246 246
247 247 # values removed
248 248 (before - after).reject(&:blank?).each do |value|
249 249 add_custom_value_detail(c, value, nil)
250 250 end
251 251 # values added
252 252 (after - before).reject(&:blank?).each do |value|
253 253 add_custom_value_detail(c, nil, value)
254 254 end
255 255 else
256 256 add_custom_value_detail(c, before, after)
257 257 end
258 258 }
259 259 end
260 260 start
261 261 end
262 262
263 263 # Adds a journal detail for an attribute change
264 264 def add_attribute_detail(attribute, old_value, value)
265 265 add_detail('attr', attribute, old_value, value)
266 266 end
267 267
268 268 # Adds a journal detail for a custom field value change
269 269 def add_custom_value_detail(custom_value, old_value, value)
270 270 add_detail('cf', custom_value.custom_field_id, old_value, value)
271 271 end
272 272
273 273 # Adds a journal detail
274 274 def add_detail(property, prop_key, old_value, value)
275 275 details << JournalDetail.new(
276 276 :property => property,
277 277 :prop_key => prop_key,
278 278 :old_value => old_value,
279 279 :value => value
280 280 )
281 281 end
282 282
283 283 def split_private_notes
284 284 if private_notes?
285 285 if notes.present?
286 286 if details.any?
287 287 # Split the journal (notes/changes) so we don't have half-private journals
288 288 journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
289 289 journal.details = details
290 290 journal.save
291 291 self.details = []
292 292 self.created_on = journal.created_on
293 293 end
294 294 else
295 295 # Blank notes should not be private
296 296 self.private_notes = false
297 297 end
298 298 end
299 299 true
300 300 end
301 301
302 302 def send_notification
303 303 if notify? && (Setting.notified_events.include?('issue_updated') ||
304 304 (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
305 305 (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
306 306 (Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
307 307 (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
308 308 )
309 309 Mailer.deliver_issue_edit(self)
310 310 end
311 311 end
312 312 end
@@ -1,198 +1,198
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Member < ActiveRecord::Base
19 19 belongs_to :user
20 20 belongs_to :principal, :foreign_key => 'user_id'
21 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 23 belongs_to :project
24 24
25 25 validates_presence_of :principal, :project
26 26 validates_uniqueness_of :user_id, :scope => :project_id
27 27 validate :validate_role
28 28 attr_protected :id
29 29
30 30 before_destroy :set_issue_category_nil
31 31
32 32 scope :active, lambda { joins(:principal).where(:users => {:status => Principal::STATUS_ACTIVE})}
33 33
34 34 alias :base_reload :reload
35 35 def reload(*args)
36 36 @managed_roles = nil
37 37 base_reload(*args)
38 38 end
39 39
40 40 def role
41 41 end
42 42
43 43 def role=
44 44 end
45 45
46 46 def name
47 47 self.user.name
48 48 end
49 49
50 50 alias :base_role_ids= :role_ids=
51 51 def role_ids=(arg)
52 52 ids = (arg || []).collect(&:to_i) - [0]
53 53 # Keep inherited roles
54 54 ids += member_roles.select {|mr| !mr.inherited_from.nil?}.collect(&:role_id)
55 55
56 56 new_role_ids = ids - role_ids
57 57 # Add new roles
58 58 new_role_ids.each {|id| member_roles << MemberRole.new(:role_id => id, :member => self) }
59 59 # Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy)
60 60 member_roles_to_destroy = member_roles.select {|mr| !ids.include?(mr.role_id)}
61 61 if member_roles_to_destroy.any?
62 62 member_roles_to_destroy.each(&:destroy)
63 63 end
64 64 end
65 65
66 66 def <=>(member)
67 67 a, b = roles.sort, member.roles.sort
68 68 if a == b
69 69 if principal
70 70 principal <=> member.principal
71 71 else
72 72 1
73 73 end
74 74 elsif a.any?
75 75 b.any? ? a <=> b : -1
76 76 else
77 77 1
78 78 end
79 79 end
80 80
81 81 # Set member role ids ignoring any change to roles that
82 82 # user is not allowed to manage
83 83 def set_editable_role_ids(ids, user=User.current)
84 84 ids = (ids || []).collect(&:to_i) - [0]
85 85 editable_role_ids = user.managed_roles(project).map(&:id)
86 86 untouched_role_ids = self.role_ids - editable_role_ids
87 87 touched_role_ids = ids & editable_role_ids
88 88 self.role_ids = untouched_role_ids + touched_role_ids
89 89 end
90 90
91 91 # Returns true if one of the member roles is inherited
92 92 def any_inherited_role?
93 93 member_roles.any? {|mr| mr.inherited_from}
94 94 end
95 95
96 96 # Returns true if the member has the role and if it's inherited
97 97 def has_inherited_role?(role)
98 98 member_roles.any? {|mr| mr.role_id == role.id && mr.inherited_from.present?}
99 99 end
100 100
101 101 # Returns true if the member's role is editable by user
102 102 def role_editable?(role, user=User.current)
103 103 if has_inherited_role?(role)
104 104 false
105 105 else
106 106 user.managed_roles(project).include?(role)
107 107 end
108 108 end
109 109
110 110 # Returns true if the member is deletable by user
111 111 def deletable?(user=User.current)
112 112 if any_inherited_role?
113 113 false
114 114 else
115 115 roles & user.managed_roles(project) == roles
116 116 end
117 117 end
118 118
119 119 # Destroys the member
120 120 def destroy
121 121 member_roles.reload.each(&:destroy_without_member_removal)
122 122 super
123 123 end
124 124
125 125 # Returns true if the member is user or is a group
126 126 # that includes user
127 127 def include?(user)
128 128 if principal.is_a?(Group)
129 129 !user.nil? && user.groups.include?(principal)
130 130 else
131 131 self.user == user
132 132 end
133 133 end
134 134
135 135 def set_issue_category_nil
136 136 if user_id && project_id
137 137 # remove category based auto assignments for this member
138 138 IssueCategory.where(["project_id = ? AND assigned_to_id = ?", project_id, user_id]).
139 139 update_all("assigned_to_id = NULL")
140 140 end
141 141 end
142 142
143 143 # Returns the roles that the member is allowed to manage
144 144 # in the project the member belongs to
145 145 def managed_roles
146 146 @managed_roles ||= begin
147 147 if principal.try(:admin?)
148 148 Role.givable.to_a
149 149 else
150 150 members_management_roles = roles.select do |role|
151 151 role.has_permission?(:manage_members)
152 152 end
153 153 if members_management_roles.empty?
154 154 []
155 155 elsif members_management_roles.any?(&:all_roles_managed?)
156 156 Role.givable.to_a
157 157 else
158 158 members_management_roles.map(&:managed_roles).reduce(&:|)
159 159 end
160 160 end
161 161 end
162 162 end
163 163
164 164 # Creates memberships for principal with the attributes
165 165 # * project_ids : one or more project ids
166 166 # * role_ids : ids of the roles to give to each membership
167 167 #
168 168 # Example:
169 169 # Member.create_principal_memberships(user, :project_ids => [2, 5], :role_ids => [1, 3]
170 170 def self.create_principal_memberships(principal, attributes)
171 171 members = []
172 172 if attributes
173 173 project_ids = Array.wrap(attributes[:project_ids] || attributes[:project_id])
174 174 role_ids = attributes[:role_ids]
175 175 project_ids.each do |project_id|
176 176 members << Member.new(:principal => principal, :role_ids => role_ids, :project_id => project_id)
177 177 end
178 178 principal.members << members
179 179 end
180 180 members
181 181 end
182 182
183 183 # Finds or initilizes a Member for the given project and principal
184 184 def self.find_or_new(project, principal)
185 185 project_id = project.is_a?(Project) ? project.id : project
186 186 principal_id = principal.is_a?(Principal) ? principal.id : principal
187 187
188 188 member = Member.find_by_project_id_and_user_id(project_id, principal_id)
189 189 member ||= Member.new(:project_id => project_id, :user_id => principal_id)
190 190 member
191 191 end
192 192
193 193 protected
194 194
195 195 def validate_role
196 196 errors.add_on_empty :role if member_roles.empty? && roles.empty?
197 197 end
198 198 end
@@ -1,1066 +1,1066
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::NestedSet::ProjectNestedSet
21 21
22 22 # Project statuses
23 23 STATUS_ACTIVE = 1
24 24 STATUS_CLOSED = 5
25 25 STATUS_ARCHIVED = 9
26 26
27 27 # Maximum length for project identifiers
28 28 IDENTIFIER_MAX_LENGTH = 100
29 29
30 30 # Specific overridden Activities
31 31 has_many :time_entry_activities
32 32 has_many :memberships, :class_name => 'Member', :inverse_of => :project
33 33 # Memberships of active users only
34 34 has_many :members,
35 35 lambda { joins(:principal).where(:users => {:type => 'User', :status => Principal::STATUS_ACTIVE}) }
36 36 has_many :enabled_modules, :dependent => :delete_all
37 37 has_and_belongs_to_many :trackers, lambda {order(:position)}
38 38 has_many :issues, :dependent => :destroy
39 39 has_many :issue_changes, :through => :issues, :source => :journals
40 40 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
41 41 belongs_to :default_version, :class_name => 'Version'
42 42 has_many :time_entries, :dependent => :destroy
43 43 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
44 44 has_many :documents, :dependent => :destroy
45 45 has_many :news, lambda {includes(:author)}, :dependent => :destroy
46 46 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
47 47 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
48 48 has_one :repository, lambda {where(["is_default = ?", true])}
49 49 has_many :repositories, :dependent => :destroy
50 50 has_many :changesets, :through => :repository
51 51 has_one :wiki, :dependent => :destroy
52 52 # Custom field for the project issues
53 53 has_and_belongs_to_many :issue_custom_fields,
54 54 lambda {order("#{CustomField.table_name}.position")},
55 55 :class_name => 'IssueCustomField',
56 56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 57 :association_foreign_key => 'custom_field_id'
58 58
59 59 acts_as_attachable :view_permission => :view_files,
60 60 :edit_permission => :manage_files,
61 61 :delete_permission => :manage_files
62 62
63 63 acts_as_customizable
64 64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => "#{Project.table_name}.id", :permission => nil
65 65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 67 :author => nil
68 68
69 69 attr_protected :status
70 70
71 71 validates_presence_of :name, :identifier
72 72 validates_uniqueness_of :identifier, :if => Proc.new {|p| p.identifier_changed?}
73 73 validates_length_of :name, :maximum => 255
74 74 validates_length_of :homepage, :maximum => 255
75 75 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
76 76 # downcase letters, digits, dashes but not digits only
77 77 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
78 78 # reserved words
79 79 validates_exclusion_of :identifier, :in => %w( new )
80 80 validate :validate_parent
81 81
82 82 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
83 83 after_save :remove_inherited_member_roles, :add_inherited_member_roles, :if => Proc.new {|project| project.parent_id_changed?}
84 84 after_update :update_versions_from_hierarchy_change, :if => Proc.new {|project| project.parent_id_changed?}
85 85 before_destroy :delete_all_members
86 86
87 87 scope :has_module, lambda {|mod|
88 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 90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 92 scope :all_public, lambda { where(:is_public => true) }
93 93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 94 scope :allowed_to, lambda {|*args|
95 95 user = User.current
96 96 permission = nil
97 97 if args.first.is_a?(Symbol)
98 98 permission = args.shift
99 99 else
100 100 user = args.shift
101 101 permission = args.shift
102 102 end
103 103 where(Project.allowed_to_condition(user, permission, *args))
104 104 }
105 105 scope :like, lambda {|arg|
106 106 if arg.blank?
107 107 where(nil)
108 108 else
109 109 pattern = "%#{arg.to_s.strip.downcase}%"
110 110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 111 end
112 112 }
113 113 scope :sorted, lambda {order(:lft)}
114 114 scope :having_trackers, lambda {
115 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 118 def initialize(attributes=nil, *args)
119 119 super
120 120
121 121 initialized = (attributes || {}).stringify_keys
122 122 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
123 123 self.identifier = Project.next_identifier
124 124 end
125 125 if !initialized.key?('is_public')
126 126 self.is_public = Setting.default_projects_public?
127 127 end
128 128 if !initialized.key?('enabled_module_names')
129 129 self.enabled_module_names = Setting.default_projects_modules
130 130 end
131 131 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
132 132 default = Setting.default_projects_tracker_ids
133 133 if default.is_a?(Array)
134 134 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
135 135 else
136 136 self.trackers = Tracker.sorted.to_a
137 137 end
138 138 end
139 139 end
140 140
141 141 def identifier=(identifier)
142 142 super unless identifier_frozen?
143 143 end
144 144
145 145 def identifier_frozen?
146 146 errors[:identifier].blank? && !(new_record? || identifier.blank?)
147 147 end
148 148
149 149 # returns latest created projects
150 150 # non public projects will be returned only if user is a member of those
151 151 def self.latest(user=nil, count=5)
152 152 visible(user).limit(count).
153 153 order(:created_on => :desc).
154 154 where("#{table_name}.created_on >= ?", 30.days.ago).
155 155 to_a
156 156 end
157 157
158 158 # Returns true if the project is visible to +user+ or to the current user.
159 159 def visible?(user=User.current)
160 160 user.allowed_to?(:view_project, self)
161 161 end
162 162
163 163 # Returns a SQL conditions string used to find all projects visible by the specified user.
164 164 #
165 165 # Examples:
166 166 # Project.visible_condition(admin) => "projects.status = 1"
167 167 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
168 168 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
169 169 def self.visible_condition(user, options={})
170 170 allowed_to_condition(user, :view_project, options)
171 171 end
172 172
173 173 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
174 174 #
175 175 # Valid options:
176 176 # * :project => limit the condition to project
177 177 # * :with_subprojects => limit the condition to project and its subprojects
178 178 # * :member => limit the condition to the user projects
179 179 def self.allowed_to_condition(user, permission, options={})
180 180 perm = Redmine::AccessControl.permission(permission)
181 181 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
182 182 if perm && perm.project_module
183 183 # If the permission belongs to a project module, make sure the module is enabled
184 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 185 end
186 186 if project = options[:project]
187 187 project_statement = project.project_condition(options[:with_subprojects])
188 188 base_statement = "(#{project_statement}) AND (#{base_statement})"
189 189 end
190 190
191 191 if user.admin?
192 192 base_statement
193 193 else
194 194 statement_by_role = {}
195 195 unless options[:member]
196 196 role = user.builtin_role
197 197 if role.allowed_to?(permission)
198 198 s = "#{Project.table_name}.is_public = #{connection.quoted_true}"
199 199 if user.id
200 200 s = "(#{s} AND #{Project.table_name}.id NOT IN (SELECT project_id FROM #{Member.table_name} WHERE user_id = #{user.id}))"
201 201 end
202 202 statement_by_role[role] = s
203 203 end
204 204 end
205 205 user.projects_by_role.each do |role, projects|
206 206 if role.allowed_to?(permission) && projects.any?
207 207 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
208 208 end
209 209 end
210 210 if statement_by_role.empty?
211 211 "1=0"
212 212 else
213 213 if block_given?
214 214 statement_by_role.each do |role, statement|
215 215 if s = yield(role, user)
216 216 statement_by_role[role] = "(#{statement} AND (#{s}))"
217 217 end
218 218 end
219 219 end
220 220 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
221 221 end
222 222 end
223 223 end
224 224
225 225 def override_roles(role)
226 226 @override_members ||= memberships.
227 227 joins(:principal).
228 228 where(:users => {:type => ['GroupAnonymous', 'GroupNonMember']}).to_a
229 229
230 230 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
231 231 member = @override_members.detect {|m| m.principal.is_a? group_class}
232 232 member ? member.roles.to_a : [role]
233 233 end
234 234
235 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 237 end
238 238
239 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 241 end
242 242
243 243 # Returns the Systemwide and project specific activities
244 244 def activities(include_inactive=false)
245 245 t = TimeEntryActivity.table_name
246 246 scope = TimeEntryActivity.where("#{t}.project_id IS NULL OR #{t}.project_id = ?", id)
247 247
248 248 overridden_activity_ids = self.time_entry_activities.pluck(:parent_id).compact
249 249 if overridden_activity_ids.any?
250 250 scope = scope.where("#{t}.id NOT IN (?)", overridden_activity_ids)
251 251 end
252 252 unless include_inactive
253 253 scope = scope.active
254 254 end
255 255 scope
256 256 end
257 257
258 258 # Will create a new Project specific Activity or update an existing one
259 259 #
260 260 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
261 261 # does not successfully save.
262 262 def update_or_create_time_entry_activity(id, activity_hash)
263 263 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
264 264 self.create_time_entry_activity_if_needed(activity_hash)
265 265 else
266 266 activity = project.time_entry_activities.find_by_id(id.to_i)
267 267 activity.update_attributes(activity_hash) if activity
268 268 end
269 269 end
270 270
271 271 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
272 272 #
273 273 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
274 274 # does not successfully save.
275 275 def create_time_entry_activity_if_needed(activity)
276 276 if activity['parent_id']
277 277 parent_activity = TimeEntryActivity.find(activity['parent_id'])
278 278 activity['name'] = parent_activity.name
279 279 activity['position'] = parent_activity.position
280 280 if Enumeration.overriding_change?(activity, parent_activity)
281 281 project_activity = self.time_entry_activities.create(activity)
282 282 if project_activity.new_record?
283 283 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
284 284 else
285 285 self.time_entries.
286 286 where(:activity_id => parent_activity.id).
287 287 update_all(:activity_id => project_activity.id)
288 288 end
289 289 end
290 290 end
291 291 end
292 292
293 293 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
294 294 #
295 295 # Examples:
296 296 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
297 297 # project.project_condition(false) => "projects.id = 1"
298 298 def project_condition(with_subprojects)
299 299 cond = "#{Project.table_name}.id = #{id}"
300 300 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
301 301 cond
302 302 end
303 303
304 304 def self.find(*args)
305 305 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
306 306 project = find_by_identifier(*args)
307 307 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
308 308 project
309 309 else
310 310 super
311 311 end
312 312 end
313 313
314 314 def self.find_by_param(*args)
315 315 self.find(*args)
316 316 end
317 317
318 318 alias :base_reload :reload
319 319 def reload(*args)
320 320 @principals = nil
321 321 @users = nil
322 322 @shared_versions = nil
323 323 @rolled_up_versions = nil
324 324 @rolled_up_trackers = nil
325 325 @all_issue_custom_fields = nil
326 326 @all_time_entry_custom_fields = nil
327 327 @to_param = nil
328 328 @allowed_parents = nil
329 329 @allowed_permissions = nil
330 330 @actions_allowed = nil
331 331 @start_date = nil
332 332 @due_date = nil
333 333 @override_members = nil
334 334 @assignable_users = nil
335 335 base_reload(*args)
336 336 end
337 337
338 338 def to_param
339 339 if new_record?
340 340 nil
341 341 else
342 342 # id is used for projects with a numeric identifier (compatibility)
343 343 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
344 344 end
345 345 end
346 346
347 347 def active?
348 348 self.status == STATUS_ACTIVE
349 349 end
350 350
351 351 def archived?
352 352 self.status == STATUS_ARCHIVED
353 353 end
354 354
355 355 # Archives the project and its descendants
356 356 def archive
357 357 # Check that there is no issue of a non descendant project that is assigned
358 358 # to one of the project or descendant versions
359 359 version_ids = self_and_descendants.joins(:versions).pluck("#{Version.table_name}.id")
360 360
361 361 if version_ids.any? &&
362 362 Issue.
363 363 includes(:project).
364 364 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
365 365 where(:fixed_version_id => version_ids).
366 366 exists?
367 367 return false
368 368 end
369 369 Project.transaction do
370 370 archive!
371 371 end
372 372 true
373 373 end
374 374
375 375 # Unarchives the project
376 376 # All its ancestors must be active
377 377 def unarchive
378 378 return false if ancestors.detect {|a| !a.active?}
379 379 update_attribute :status, STATUS_ACTIVE
380 380 end
381 381
382 382 def close
383 383 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
384 384 end
385 385
386 386 def reopen
387 387 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
388 388 end
389 389
390 390 # Returns an array of projects the project can be moved to
391 391 # by the current user
392 392 def allowed_parents(user=User.current)
393 393 return @allowed_parents if @allowed_parents
394 394 @allowed_parents = Project.allowed_to(user, :add_subprojects).to_a
395 395 @allowed_parents = @allowed_parents - self_and_descendants
396 396 if user.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
397 397 @allowed_parents << nil
398 398 end
399 399 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
400 400 @allowed_parents << parent
401 401 end
402 402 @allowed_parents
403 403 end
404 404
405 405 # Sets the parent of the project with authorization check
406 406 def set_allowed_parent!(p)
407 407 ActiveSupport::Deprecation.warn "Project#set_allowed_parent! is deprecated and will be removed in Redmine 4, use #safe_attributes= instead."
408 408 p = p.id if p.is_a?(Project)
409 409 send :safe_attributes, {:project_id => p}
410 410 save
411 411 end
412 412
413 413 # Sets the parent of the project and saves the project
414 414 # Argument can be either a Project, a String, a Fixnum or nil
415 415 def set_parent!(p)
416 416 if p.is_a?(Project)
417 417 self.parent = p
418 418 else
419 419 self.parent_id = p
420 420 end
421 421 save
422 422 end
423 423
424 424 # Returns a scope of the trackers used by the project and its active sub projects
425 425 def rolled_up_trackers(include_subprojects=true)
426 426 if include_subprojects
427 427 @rolled_up_trackers ||= rolled_up_trackers_base_scope.
428 428 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt)
429 429 else
430 430 rolled_up_trackers_base_scope.
431 431 where(:projects => {:id => id})
432 432 end
433 433 end
434 434
435 435 def rolled_up_trackers_base_scope
436 436 Tracker.
437 437 joins(projects: :enabled_modules).
438 438 where("#{Project.table_name}.status <> ?", STATUS_ARCHIVED).
439 439 where(:enabled_modules => {:name => 'issue_tracking'}).
440 uniq.
440 distinct.
441 441 sorted
442 442 end
443 443
444 444 # Closes open and locked project versions that are completed
445 445 def close_completed_versions
446 446 Version.transaction do
447 447 versions.where(:status => %w(open locked)).each do |version|
448 448 if version.completed?
449 449 version.update_attribute(:status, 'closed')
450 450 end
451 451 end
452 452 end
453 453 end
454 454
455 455 # Returns a scope of the Versions on subprojects
456 456 def rolled_up_versions
457 457 @rolled_up_versions ||=
458 458 Version.
459 459 joins(:project).
460 460 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
461 461 end
462 462
463 463 # Returns a scope of the Versions used by the project
464 464 def shared_versions
465 465 if new_record?
466 466 Version.
467 467 joins(:project).
468 468 preload(:project).
469 469 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
470 470 else
471 471 @shared_versions ||= begin
472 472 r = root? ? self : root
473 473 Version.
474 474 joins(:project).
475 475 preload(:project).
476 476 where("#{Project.table_name}.id = #{id}" +
477 477 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
478 478 " #{Version.table_name}.sharing = 'system'" +
479 479 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
480 480 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
481 481 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
482 482 "))")
483 483 end
484 484 end
485 485 end
486 486
487 487 # Returns a hash of project users grouped by role
488 488 def users_by_role
489 489 members.includes(:user, :roles).inject({}) do |h, m|
490 490 m.roles.each do |r|
491 491 h[r] ||= []
492 492 h[r] << m.user
493 493 end
494 494 h
495 495 end
496 496 end
497 497
498 498 # Adds user as a project member with the default role
499 499 # Used for when a non-admin user creates a project
500 500 def add_default_member(user)
501 501 role = Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
502 502 member = Member.new(:project => self, :principal => user, :roles => [role])
503 503 self.members << member
504 504 member
505 505 end
506 506
507 507 # Deletes all project's members
508 508 def delete_all_members
509 509 me, mr = Member.table_name, MemberRole.table_name
510 510 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
511 511 Member.delete_all(['project_id = ?', id])
512 512 end
513 513
514 514 # Return a Principal scope of users/groups issues can be assigned to
515 515 def assignable_users(tracker=nil)
516 516 return @assignable_users[tracker] if @assignable_users && @assignable_users[tracker]
517 517
518 518 types = ['User']
519 519 types << 'Group' if Setting.issue_group_assignment?
520 520
521 521 scope = Principal.
522 522 active.
523 523 joins(:members => :roles).
524 524 where(:type => types, :members => {:project_id => id}, :roles => {:assignable => true}).
525 uniq.
525 distinct.
526 526 sorted
527 527
528 528 if tracker
529 529 # Rejects users that cannot the view the tracker
530 530 roles = Role.where(:assignable => true).select {|role| role.permissions_tracker?(:view_issues, tracker)}
531 531 scope = scope.where(:roles => {:id => roles.map(&:id)})
532 532 end
533 533
534 534 @assignable_users ||= {}
535 535 @assignable_users[tracker] = scope
536 536 end
537 537
538 538 # Returns the mail addresses of users that should be always notified on project events
539 539 def recipients
540 540 notified_users.collect {|user| user.mail}
541 541 end
542 542
543 543 # Returns the users that should be notified on project events
544 544 def notified_users
545 545 # TODO: User part should be extracted to User#notify_about?
546 546 members.preload(:principal).select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
547 547 end
548 548
549 549 # Returns a scope of all custom fields enabled for project issues
550 550 # (explicitly associated custom fields and custom fields enabled for all projects)
551 551 def all_issue_custom_fields
552 552 if new_record?
553 553 @all_issue_custom_fields ||= IssueCustomField.
554 554 sorted.
555 555 where("is_for_all = ? OR id IN (?)", true, issue_custom_field_ids)
556 556 else
557 557 @all_issue_custom_fields ||= IssueCustomField.
558 558 sorted.
559 559 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
560 560 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
561 561 " WHERE cfp.project_id = ?)", true, id)
562 562 end
563 563 end
564 564
565 565 def project
566 566 self
567 567 end
568 568
569 569 def <=>(project)
570 570 name.casecmp(project.name)
571 571 end
572 572
573 573 def to_s
574 574 name
575 575 end
576 576
577 577 # Returns a short description of the projects (first lines)
578 578 def short_description(length = 255)
579 579 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
580 580 end
581 581
582 582 def css_classes
583 583 s = 'project'
584 584 s << ' root' if root?
585 585 s << ' child' if child?
586 586 s << (leaf? ? ' leaf' : ' parent')
587 587 unless active?
588 588 if archived?
589 589 s << ' archived'
590 590 else
591 591 s << ' closed'
592 592 end
593 593 end
594 594 s
595 595 end
596 596
597 597 # The earliest start date of a project, based on it's issues and versions
598 598 def start_date
599 599 @start_date ||= [
600 600 issues.minimum('start_date'),
601 601 shared_versions.minimum('effective_date'),
602 602 Issue.fixed_version(shared_versions).minimum('start_date')
603 603 ].compact.min
604 604 end
605 605
606 606 # The latest due date of an issue or version
607 607 def due_date
608 608 @due_date ||= [
609 609 issues.maximum('due_date'),
610 610 shared_versions.maximum('effective_date'),
611 611 Issue.fixed_version(shared_versions).maximum('due_date')
612 612 ].compact.max
613 613 end
614 614
615 615 def overdue?
616 616 active? && !due_date.nil? && (due_date < User.current.today)
617 617 end
618 618
619 619 # Returns the percent completed for this project, based on the
620 620 # progress on it's versions.
621 621 def completed_percent(options={:include_subprojects => false})
622 622 if options.delete(:include_subprojects)
623 623 total = self_and_descendants.collect(&:completed_percent).sum
624 624
625 625 total / self_and_descendants.count
626 626 else
627 627 if versions.count > 0
628 628 total = versions.collect(&:completed_percent).sum
629 629
630 630 total / versions.count
631 631 else
632 632 100
633 633 end
634 634 end
635 635 end
636 636
637 637 # Return true if this project allows to do the specified action.
638 638 # action can be:
639 639 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
640 640 # * a permission Symbol (eg. :edit_project)
641 641 def allows_to?(action)
642 642 if archived?
643 643 # No action allowed on archived projects
644 644 return false
645 645 end
646 646 unless active? || Redmine::AccessControl.read_action?(action)
647 647 # No write action allowed on closed projects
648 648 return false
649 649 end
650 650 # No action allowed on disabled modules
651 651 if action.is_a? Hash
652 652 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
653 653 else
654 654 allowed_permissions.include? action
655 655 end
656 656 end
657 657
658 658 # Return the enabled module with the given name
659 659 # or nil if the module is not enabled for the project
660 660 def enabled_module(name)
661 661 name = name.to_s
662 662 enabled_modules.detect {|m| m.name == name}
663 663 end
664 664
665 665 # Return true if the module with the given name is enabled
666 666 def module_enabled?(name)
667 667 enabled_module(name).present?
668 668 end
669 669
670 670 def enabled_module_names=(module_names)
671 671 if module_names && module_names.is_a?(Array)
672 672 module_names = module_names.collect(&:to_s).reject(&:blank?)
673 673 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
674 674 else
675 675 enabled_modules.clear
676 676 end
677 677 end
678 678
679 679 # Returns an array of the enabled modules names
680 680 def enabled_module_names
681 681 enabled_modules.collect(&:name)
682 682 end
683 683
684 684 # Enable a specific module
685 685 #
686 686 # Examples:
687 687 # project.enable_module!(:issue_tracking)
688 688 # project.enable_module!("issue_tracking")
689 689 def enable_module!(name)
690 690 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
691 691 end
692 692
693 693 # Disable a module if it exists
694 694 #
695 695 # Examples:
696 696 # project.disable_module!(:issue_tracking)
697 697 # project.disable_module!("issue_tracking")
698 698 # project.disable_module!(project.enabled_modules.first)
699 699 def disable_module!(target)
700 700 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
701 701 target.destroy unless target.blank?
702 702 end
703 703
704 704 safe_attributes 'name',
705 705 'description',
706 706 'homepage',
707 707 'is_public',
708 708 'identifier',
709 709 'custom_field_values',
710 710 'custom_fields',
711 711 'tracker_ids',
712 712 'issue_custom_field_ids',
713 713 'parent_id',
714 714 'default_version_id'
715 715
716 716 safe_attributes 'enabled_module_names',
717 717 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
718 718
719 719 safe_attributes 'inherit_members',
720 720 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
721 721
722 722 def safe_attributes=(attrs, user=User.current)
723 723 return unless attrs.is_a?(Hash)
724 724 attrs = attrs.deep_dup
725 725
726 726 @unallowed_parent_id = nil
727 727 if new_record? || attrs.key?('parent_id')
728 728 parent_id_param = attrs['parent_id'].to_s
729 729 if new_record? || parent_id_param != parent_id.to_s
730 730 p = parent_id_param.present? ? Project.find_by_id(parent_id_param) : nil
731 731 unless allowed_parents(user).include?(p)
732 732 attrs.delete('parent_id')
733 733 @unallowed_parent_id = true
734 734 end
735 735 end
736 736 end
737 737
738 738 super(attrs, user)
739 739 end
740 740
741 741 # Returns an auto-generated project identifier based on the last identifier used
742 742 def self.next_identifier
743 743 p = Project.order('id DESC').first
744 744 p.nil? ? nil : p.identifier.to_s.succ
745 745 end
746 746
747 747 # Copies and saves the Project instance based on the +project+.
748 748 # Duplicates the source project's:
749 749 # * Wiki
750 750 # * Versions
751 751 # * Categories
752 752 # * Issues
753 753 # * Members
754 754 # * Queries
755 755 #
756 756 # Accepts an +options+ argument to specify what to copy
757 757 #
758 758 # Examples:
759 759 # project.copy(1) # => copies everything
760 760 # project.copy(1, :only => 'members') # => copies members only
761 761 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
762 762 def copy(project, options={})
763 763 project = project.is_a?(Project) ? project : Project.find(project)
764 764
765 765 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
766 766 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
767 767
768 768 Project.transaction do
769 769 if save
770 770 reload
771 771 to_be_copied.each do |name|
772 772 send "copy_#{name}", project
773 773 end
774 774 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
775 775 save
776 776 else
777 777 false
778 778 end
779 779 end
780 780 end
781 781
782 782 def member_principals
783 783 ActiveSupport::Deprecation.warn "Project#member_principals is deprecated and will be removed in Redmine 4.0. Use #memberships.active instead."
784 784 memberships.active
785 785 end
786 786
787 787 # Returns a new unsaved Project instance with attributes copied from +project+
788 788 def self.copy_from(project)
789 789 project = project.is_a?(Project) ? project : Project.find(project)
790 790 # clear unique attributes
791 791 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
792 792 copy = Project.new(attributes)
793 793 copy.enabled_module_names = project.enabled_module_names
794 794 copy.trackers = project.trackers
795 795 copy.custom_values = project.custom_values.collect {|v| v.clone}
796 796 copy.issue_custom_fields = project.issue_custom_fields
797 797 copy
798 798 end
799 799
800 800 # Yields the given block for each project with its level in the tree
801 801 def self.project_tree(projects, &block)
802 802 ancestors = []
803 803 projects.sort_by(&:lft).each do |project|
804 804 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
805 805 ancestors.pop
806 806 end
807 807 yield project, ancestors.size
808 808 ancestors << project
809 809 end
810 810 end
811 811
812 812 private
813 813
814 814 def update_inherited_members
815 815 if parent
816 816 if inherit_members? && !inherit_members_was
817 817 remove_inherited_member_roles
818 818 add_inherited_member_roles
819 819 elsif !inherit_members? && inherit_members_was
820 820 remove_inherited_member_roles
821 821 end
822 822 end
823 823 end
824 824
825 825 def remove_inherited_member_roles
826 826 member_roles = memberships.map(&:member_roles).flatten
827 827 member_role_ids = member_roles.map(&:id)
828 828 member_roles.each do |member_role|
829 829 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
830 830 member_role.destroy
831 831 end
832 832 end
833 833 end
834 834
835 835 def add_inherited_member_roles
836 836 if inherit_members? && parent
837 837 parent.memberships.each do |parent_member|
838 838 member = Member.find_or_new(self.id, parent_member.user_id)
839 839 parent_member.member_roles.each do |parent_member_role|
840 840 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
841 841 end
842 842 member.save!
843 843 end
844 844 memberships.reset
845 845 end
846 846 end
847 847
848 848 def update_versions_from_hierarchy_change
849 849 Issue.update_versions_from_hierarchy_change(self)
850 850 end
851 851
852 852 def validate_parent
853 853 if @unallowed_parent_id
854 854 errors.add(:parent_id, :invalid)
855 855 elsif parent_id_changed?
856 856 unless parent.nil? || (parent.active? && move_possible?(parent))
857 857 errors.add(:parent_id, :invalid)
858 858 end
859 859 end
860 860 end
861 861
862 862 # Copies wiki from +project+
863 863 def copy_wiki(project)
864 864 # Check that the source project has a wiki first
865 865 unless project.wiki.nil?
866 866 wiki = self.wiki || Wiki.new
867 867 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
868 868 wiki_pages_map = {}
869 869 project.wiki.pages.each do |page|
870 870 # Skip pages without content
871 871 next if page.content.nil?
872 872 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
873 873 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
874 874 new_wiki_page.content = new_wiki_content
875 875 wiki.pages << new_wiki_page
876 876 wiki_pages_map[page.id] = new_wiki_page
877 877 end
878 878
879 879 self.wiki = wiki
880 880 wiki.save
881 881 # Reproduce page hierarchy
882 882 project.wiki.pages.each do |page|
883 883 if page.parent_id && wiki_pages_map[page.id]
884 884 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
885 885 wiki_pages_map[page.id].save
886 886 end
887 887 end
888 888 end
889 889 end
890 890
891 891 # Copies versions from +project+
892 892 def copy_versions(project)
893 893 project.versions.each do |version|
894 894 new_version = Version.new
895 895 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
896 896 self.versions << new_version
897 897 end
898 898 end
899 899
900 900 # Copies issue categories from +project+
901 901 def copy_issue_categories(project)
902 902 project.issue_categories.each do |issue_category|
903 903 new_issue_category = IssueCategory.new
904 904 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
905 905 self.issue_categories << new_issue_category
906 906 end
907 907 end
908 908
909 909 # Copies issues from +project+
910 910 def copy_issues(project)
911 911 # Stores the source issue id as a key and the copied issues as the
912 912 # value. Used to map the two together for issue relations.
913 913 issues_map = {}
914 914
915 915 # Store status and reopen locked/closed versions
916 916 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
917 917 version_statuses.each do |version, status|
918 918 version.update_attribute :status, 'open'
919 919 end
920 920
921 921 # Get issues sorted by root_id, lft so that parent issues
922 922 # get copied before their children
923 923 project.issues.reorder('root_id, lft').each do |issue|
924 924 new_issue = Issue.new
925 925 new_issue.copy_from(issue, :subtasks => false, :link => false)
926 926 new_issue.project = self
927 927 # Changing project resets the custom field values
928 928 # TODO: handle this in Issue#project=
929 929 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
930 930 # Reassign fixed_versions by name, since names are unique per project
931 931 if issue.fixed_version && issue.fixed_version.project == project
932 932 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
933 933 end
934 934 # Reassign version custom field values
935 935 new_issue.custom_field_values.each do |custom_value|
936 936 if custom_value.custom_field.field_format == 'version' && custom_value.value.present?
937 937 versions = Version.where(:id => custom_value.value).to_a
938 938 new_value = versions.map do |version|
939 939 if version.project == project
940 940 self.versions.detect {|v| v.name == version.name}.try(:id)
941 941 else
942 942 version.id
943 943 end
944 944 end
945 945 new_value.compact!
946 946 new_value = new_value.first unless custom_value.custom_field.multiple?
947 947 custom_value.value = new_value
948 948 end
949 949 end
950 950 # Reassign the category by name, since names are unique per project
951 951 if issue.category
952 952 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
953 953 end
954 954 # Parent issue
955 955 if issue.parent_id
956 956 if copied_parent = issues_map[issue.parent_id]
957 957 new_issue.parent_issue_id = copied_parent.id
958 958 end
959 959 end
960 960
961 961 self.issues << new_issue
962 962 if new_issue.new_record?
963 963 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info?
964 964 else
965 965 issues_map[issue.id] = new_issue unless new_issue.new_record?
966 966 end
967 967 end
968 968
969 969 # Restore locked/closed version statuses
970 970 version_statuses.each do |version, status|
971 971 version.update_attribute :status, status
972 972 end
973 973
974 974 # Relations after in case issues related each other
975 975 project.issues.each do |issue|
976 976 new_issue = issues_map[issue.id]
977 977 unless new_issue
978 978 # Issue was not copied
979 979 next
980 980 end
981 981
982 982 # Relations
983 983 issue.relations_from.each do |source_relation|
984 984 new_issue_relation = IssueRelation.new
985 985 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
986 986 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
987 987 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
988 988 new_issue_relation.issue_to = source_relation.issue_to
989 989 end
990 990 new_issue.relations_from << new_issue_relation
991 991 end
992 992
993 993 issue.relations_to.each do |source_relation|
994 994 new_issue_relation = IssueRelation.new
995 995 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
996 996 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
997 997 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
998 998 new_issue_relation.issue_from = source_relation.issue_from
999 999 end
1000 1000 new_issue.relations_to << new_issue_relation
1001 1001 end
1002 1002 end
1003 1003 end
1004 1004
1005 1005 # Copies members from +project+
1006 1006 def copy_members(project)
1007 1007 # Copy users first, then groups to handle members with inherited and given roles
1008 1008 members_to_copy = []
1009 1009 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
1010 1010 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
1011 1011
1012 1012 members_to_copy.each do |member|
1013 1013 new_member = Member.new
1014 1014 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
1015 1015 # only copy non inherited roles
1016 1016 # inherited roles will be added when copying the group membership
1017 1017 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
1018 1018 next if role_ids.empty?
1019 1019 new_member.role_ids = role_ids
1020 1020 new_member.project = self
1021 1021 self.members << new_member
1022 1022 end
1023 1023 end
1024 1024
1025 1025 # Copies queries from +project+
1026 1026 def copy_queries(project)
1027 1027 project.queries.each do |query|
1028 1028 new_query = IssueQuery.new
1029 1029 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
1030 1030 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
1031 1031 new_query.project = self
1032 1032 new_query.user_id = query.user_id
1033 1033 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
1034 1034 self.queries << new_query
1035 1035 end
1036 1036 end
1037 1037
1038 1038 # Copies boards from +project+
1039 1039 def copy_boards(project)
1040 1040 project.boards.each do |board|
1041 1041 new_board = Board.new
1042 1042 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
1043 1043 new_board.project = self
1044 1044 self.boards << new_board
1045 1045 end
1046 1046 end
1047 1047
1048 1048 def allowed_permissions
1049 1049 @allowed_permissions ||= begin
1050 1050 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
1051 1051 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
1052 1052 end
1053 1053 end
1054 1054
1055 1055 def allowed_actions
1056 1056 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
1057 1057 end
1058 1058
1059 1059 # Archives subprojects recursively
1060 1060 def archive!
1061 1061 children.each do |subproject|
1062 1062 subproject.send :archive!
1063 1063 end
1064 1064 update_attribute :status, STATUS_ARCHIVED
1065 1065 end
1066 1066 end
@@ -1,510 +1,510
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ScmFetchError < Exception; end
19 19
20 20 class Repository < ActiveRecord::Base
21 21 include Redmine::Ciphering
22 22 include Redmine::SafeAttributes
23 23
24 24 # Maximum length for repository identifiers
25 25 IDENTIFIER_MAX_LENGTH = 255
26 26
27 27 belongs_to :project
28 28 has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
29 29 has_many :filechanges, :class_name => 'Change', :through => :changesets
30 30
31 31 serialize :extra_info
32 32
33 33 before_validation :normalize_identifier
34 34 before_save :check_default
35 35
36 36 # Raw SQL to delete changesets and changes in the database
37 37 # has_many :changesets, :dependent => :destroy is too slow for big repositories
38 38 before_destroy :clear_changesets
39 39
40 40 validates_length_of :password, :maximum => 255, :allow_nil => true
41 41 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
42 42 validates_uniqueness_of :identifier, :scope => :project_id
43 43 validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
44 44 # donwcase letters, digits, dashes, underscores but not digits only
45 45 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
46 46 # Checks if the SCM is enabled when creating a repository
47 47 validate :repo_create_validation, :on => :create
48 48 validate :validate_repository_path
49 49 attr_protected :id
50 50
51 51 safe_attributes 'identifier',
52 52 'login',
53 53 'password',
54 54 'path_encoding',
55 55 'log_encoding',
56 56 'is_default'
57 57
58 58 safe_attributes 'url',
59 59 :if => lambda {|repository, user| repository.new_record?}
60 60
61 61 def repo_create_validation
62 62 unless Setting.enabled_scm.include?(self.class.name.demodulize)
63 63 errors.add(:type, :invalid)
64 64 end
65 65 end
66 66
67 67 def self.human_attribute_name(attribute_key_name, *args)
68 68 attr_name = attribute_key_name.to_s
69 69 if attr_name == "log_encoding"
70 70 attr_name = "commit_logs_encoding"
71 71 end
72 72 super(attr_name, *args)
73 73 end
74 74
75 75 # Removes leading and trailing whitespace
76 76 def url=(arg)
77 77 write_attribute(:url, arg ? arg.to_s.strip : nil)
78 78 end
79 79
80 80 # Removes leading and trailing whitespace
81 81 def root_url=(arg)
82 82 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
83 83 end
84 84
85 85 def password
86 86 read_ciphered_attribute(:password)
87 87 end
88 88
89 89 def password=(arg)
90 90 write_ciphered_attribute(:password, arg)
91 91 end
92 92
93 93 def scm_adapter
94 94 self.class.scm_adapter_class
95 95 end
96 96
97 97 def scm
98 98 unless @scm
99 99 @scm = self.scm_adapter.new(url, root_url,
100 100 login, password, path_encoding)
101 101 if root_url.blank? && @scm.root_url.present?
102 102 update_attribute(:root_url, @scm.root_url)
103 103 end
104 104 end
105 105 @scm
106 106 end
107 107
108 108 def scm_name
109 109 self.class.scm_name
110 110 end
111 111
112 112 def name
113 113 if identifier.present?
114 114 identifier
115 115 elsif is_default?
116 116 l(:field_repository_is_default)
117 117 else
118 118 scm_name
119 119 end
120 120 end
121 121
122 122 def identifier=(identifier)
123 123 super unless identifier_frozen?
124 124 end
125 125
126 126 def identifier_frozen?
127 127 errors[:identifier].blank? && !(new_record? || identifier.blank?)
128 128 end
129 129
130 130 def identifier_param
131 131 if is_default?
132 132 nil
133 133 elsif identifier.present?
134 134 identifier
135 135 else
136 136 id.to_s
137 137 end
138 138 end
139 139
140 140 def <=>(repository)
141 141 if is_default?
142 142 -1
143 143 elsif repository.is_default?
144 144 1
145 145 else
146 146 identifier.to_s <=> repository.identifier.to_s
147 147 end
148 148 end
149 149
150 150 def self.find_by_identifier_param(param)
151 151 if param.to_s =~ /^\d+$/
152 152 find_by_id(param)
153 153 else
154 154 find_by_identifier(param)
155 155 end
156 156 end
157 157
158 158 # TODO: should return an empty hash instead of nil to avoid many ||{}
159 159 def extra_info
160 160 h = read_attribute(:extra_info)
161 161 h.is_a?(Hash) ? h : nil
162 162 end
163 163
164 164 def merge_extra_info(arg)
165 165 h = extra_info || {}
166 166 return h if arg.nil?
167 167 h.merge!(arg)
168 168 write_attribute(:extra_info, h)
169 169 end
170 170
171 171 def report_last_commit
172 172 true
173 173 end
174 174
175 175 def supports_cat?
176 176 scm.supports_cat?
177 177 end
178 178
179 179 def supports_annotate?
180 180 scm.supports_annotate?
181 181 end
182 182
183 183 def supports_all_revisions?
184 184 true
185 185 end
186 186
187 187 def supports_directory_revisions?
188 188 false
189 189 end
190 190
191 191 def supports_revision_graph?
192 192 false
193 193 end
194 194
195 195 def entry(path=nil, identifier=nil)
196 196 scm.entry(path, identifier)
197 197 end
198 198
199 199 def scm_entries(path=nil, identifier=nil)
200 200 scm.entries(path, identifier)
201 201 end
202 202 protected :scm_entries
203 203
204 204 def entries(path=nil, identifier=nil)
205 205 entries = scm_entries(path, identifier)
206 206 load_entries_changesets(entries)
207 207 entries
208 208 end
209 209
210 210 def branches
211 211 scm.branches
212 212 end
213 213
214 214 def tags
215 215 scm.tags
216 216 end
217 217
218 218 def default_branch
219 219 nil
220 220 end
221 221
222 222 def properties(path, identifier=nil)
223 223 scm.properties(path, identifier)
224 224 end
225 225
226 226 def cat(path, identifier=nil)
227 227 scm.cat(path, identifier)
228 228 end
229 229
230 230 def diff(path, rev, rev_to)
231 231 scm.diff(path, rev, rev_to)
232 232 end
233 233
234 234 def diff_format_revisions(cs, cs_to, sep=':')
235 235 text = ""
236 236 text << cs_to.format_identifier + sep if cs_to
237 237 text << cs.format_identifier if cs
238 238 text
239 239 end
240 240
241 241 # Returns a path relative to the url of the repository
242 242 def relative_path(path)
243 243 path
244 244 end
245 245
246 246 # Finds and returns a revision with a number or the beginning of a hash
247 247 def find_changeset_by_name(name)
248 248 return nil if name.blank?
249 249 s = name.to_s
250 250 if s.match(/^\d*$/)
251 251 changesets.where("revision = ?", s).first
252 252 else
253 253 changesets.where("revision LIKE ?", s + '%').first
254 254 end
255 255 end
256 256
257 257 def latest_changeset
258 258 @latest_changeset ||= changesets.first
259 259 end
260 260
261 261 # Returns the latest changesets for +path+
262 262 # Default behaviour is to search in cached changesets
263 263 def latest_changesets(path, rev, limit=10)
264 264 if path.blank?
265 265 changesets.
266 266 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
267 267 limit(limit).
268 268 preload(:user).
269 269 to_a
270 270 else
271 271 filechanges.
272 272 where("path = ?", path.with_leading_slash).
273 273 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
274 274 limit(limit).
275 275 preload(:changeset => :user).
276 276 collect(&:changeset)
277 277 end
278 278 end
279 279
280 280 def scan_changesets_for_issue_ids
281 281 self.changesets.each(&:scan_comment_for_issue_ids)
282 282 end
283 283
284 284 # Returns an array of committers usernames and associated user_id
285 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 287 end
288 288
289 289 # Maps committers username to a user ids
290 290 def committer_ids=(h)
291 291 if h.is_a?(Hash)
292 292 committers.each do |committer, user_id|
293 293 new_user_id = h[committer]
294 294 if new_user_id && (new_user_id.to_i != user_id.to_i)
295 295 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
296 296 Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
297 297 update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
298 298 end
299 299 end
300 300 @committers = nil
301 301 @found_committer_users = nil
302 302 true
303 303 else
304 304 false
305 305 end
306 306 end
307 307
308 308 # Returns the Redmine User corresponding to the given +committer+
309 309 # It will return nil if the committer is not yet mapped and if no User
310 310 # with the same username or email was found
311 311 def find_committer_user(committer)
312 312 unless committer.blank?
313 313 @found_committer_users ||= {}
314 314 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
315 315
316 316 user = nil
317 317 c = changesets.where(:committer => committer).
318 318 includes(:user).references(:user).first
319 319 if c && c.user
320 320 user = c.user
321 321 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
322 322 username, email = $1.strip, $3
323 323 u = User.find_by_login(username)
324 324 u ||= User.find_by_mail(email) unless email.blank?
325 325 user = u
326 326 end
327 327 @found_committer_users[committer] = user
328 328 user
329 329 end
330 330 end
331 331
332 332 def repo_log_encoding
333 333 encoding = log_encoding.to_s.strip
334 334 encoding.blank? ? 'UTF-8' : encoding
335 335 end
336 336
337 337 # Fetches new changesets for all repositories of active projects
338 338 # Can be called periodically by an external script
339 339 # eg. ruby script/runner "Repository.fetch_changesets"
340 340 def self.fetch_changesets
341 341 Project.active.has_module(:repository).all.each do |project|
342 342 project.repositories.each do |repository|
343 343 begin
344 344 repository.fetch_changesets
345 345 rescue Redmine::Scm::Adapters::CommandFailed => e
346 346 logger.error "scm: error during fetching changesets: #{e.message}"
347 347 end
348 348 end
349 349 end
350 350 end
351 351
352 352 # scan changeset comments to find related and fixed issues for all repositories
353 353 def self.scan_changesets_for_issue_ids
354 354 all.each(&:scan_changesets_for_issue_ids)
355 355 end
356 356
357 357 def self.scm_name
358 358 'Abstract'
359 359 end
360 360
361 361 def self.available_scm
362 362 subclasses.collect {|klass| [klass.scm_name, klass.name]}
363 363 end
364 364
365 365 def self.factory(klass_name, *args)
366 366 klass = "Repository::#{klass_name}".constantize
367 367 klass.new(*args)
368 368 rescue
369 369 nil
370 370 end
371 371
372 372 def self.scm_adapter_class
373 373 nil
374 374 end
375 375
376 376 def self.scm_command
377 377 ret = ""
378 378 begin
379 379 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
380 380 rescue Exception => e
381 381 logger.error "scm: error during get command: #{e.message}"
382 382 end
383 383 ret
384 384 end
385 385
386 386 def self.scm_version_string
387 387 ret = ""
388 388 begin
389 389 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
390 390 rescue Exception => e
391 391 logger.error "scm: error during get version string: #{e.message}"
392 392 end
393 393 ret
394 394 end
395 395
396 396 def self.scm_available
397 397 ret = false
398 398 begin
399 399 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
400 400 rescue Exception => e
401 401 logger.error "scm: error during get scm available: #{e.message}"
402 402 end
403 403 ret
404 404 end
405 405
406 406 def set_as_default?
407 407 new_record? && project && Repository.where(:project_id => project.id).empty?
408 408 end
409 409
410 410 # Returns a hash with statistics by author in the following form:
411 411 # {
412 412 # "John Smith" => { :commits => 45, :changes => 324 },
413 413 # "Bob" => { ... }
414 414 # }
415 415 #
416 416 # Notes:
417 417 # - this hash honnors the users mapping defined for the repository
418 418 def stats_by_author
419 419 commits = Changeset.where("repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
420 420
421 421 #TODO: restore ordering ; this line probably never worked
422 422 #commits.to_a.sort! {|x, y| x.last <=> y.last}
423 423
424 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 426 user_ids = changesets.map(&:user_id).compact.uniq
427 427 authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
428 428 memo[user.id] = user.to_s
429 429 memo
430 430 end
431 431
432 432 (commits + changes).inject({}) do |hash, element|
433 433 mapped_name = element.committer
434 434 if username = authors_names[element.user_id.to_i]
435 435 mapped_name = username
436 436 end
437 437 hash[mapped_name] ||= { :commits_count => 0, :changes_count => 0 }
438 438 if element.is_a?(Changeset)
439 439 hash[mapped_name][:commits_count] += element.count.to_i
440 440 else
441 441 hash[mapped_name][:changes_count] += element.count.to_i
442 442 end
443 443 hash
444 444 end
445 445 end
446 446
447 447 # Returns a scope of changesets that come from the same commit as the given changeset
448 448 # in different repositories that point to the same backend
449 449 def same_commits_in_scope(scope, changeset)
450 450 scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
451 451 if changeset.scmid.present?
452 452 scope = scope.where(:scmid => changeset.scmid)
453 453 else
454 454 scope = scope.where(:revision => changeset.revision)
455 455 end
456 456 scope
457 457 end
458 458
459 459 protected
460 460
461 461 # Validates repository url based against an optional regular expression
462 462 # that can be set in the Redmine configuration file.
463 463 def validate_repository_path(attribute=:url)
464 464 regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
465 465 if changes[attribute] && regexp.present?
466 466 regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
467 467 unless send(attribute).to_s.match(Regexp.new("\\A#{regexp}\\z"))
468 468 errors.add(attribute, :invalid)
469 469 end
470 470 end
471 471 end
472 472
473 473 def normalize_identifier
474 474 self.identifier = identifier.to_s.strip
475 475 end
476 476
477 477 def check_default
478 478 if !is_default? && set_as_default?
479 479 self.is_default = true
480 480 end
481 481 if is_default? && is_default_changed?
482 482 Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
483 483 end
484 484 end
485 485
486 486 def load_entries_changesets(entries)
487 487 if entries
488 488 entries.each do |entry|
489 489 if entry.lastrev && entry.lastrev.identifier
490 490 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
491 491 end
492 492 end
493 493 end
494 494 end
495 495
496 496 private
497 497
498 498 # Deletes repository data
499 499 def clear_changesets
500 500 cs = Changeset.table_name
501 501 ch = Change.table_name
502 502 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
503 503 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
504 504
505 505 self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
506 506 self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
507 507 self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
508 508 self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
509 509 end
510 510 end
@@ -1,137 +1,137
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Tracker < ActiveRecord::Base
19 19
20 20 CORE_FIELDS_UNDISABLABLE = %w(project_id tracker_id subject description priority_id is_private).freeze
21 21 # Fields that can be disabled
22 22 # Other (future) fields should be appended, not inserted!
23 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 24 CORE_FIELDS_ALL = (CORE_FIELDS_UNDISABLABLE + CORE_FIELDS).freeze
25 25
26 26 before_destroy :check_integrity
27 27 belongs_to :default_status, :class_name => 'IssueStatus'
28 28 has_many :issues
29 29 has_many :workflow_rules, :dependent => :delete_all do
30 30 def copy(source_tracker)
31 31 WorkflowRule.copy(source_tracker, nil, proxy_association.owner, nil)
32 32 end
33 33 end
34 34
35 35 has_and_belongs_to_many :projects
36 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 37 acts_as_positioned
38 38
39 39 attr_protected :fields_bits
40 40
41 41 validates_presence_of :default_status
42 42 validates_presence_of :name
43 43 validates_uniqueness_of :name
44 44 validates_length_of :name, :maximum => 30
45 45
46 46 scope :sorted, lambda { order(:position) }
47 47 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
48 48
49 49 # Returns the trackers that are visible by the user.
50 50 #
51 51 # Examples:
52 52 # project.trackers.visible(user)
53 53 # => returns the trackers that are visible by the user in project
54 54 #
55 55 # Tracker.visible(user)
56 56 # => returns the trackers that are visible by the user in at least on project
57 57 scope :visible, lambda {|*args|
58 58 user = args.shift || User.current
59 59 condition = Project.allowed_to_condition(user, :view_issues) do |role, user|
60 60 unless role.permissions_all_trackers?(:view_issues)
61 61 tracker_ids = role.permissions_tracker_ids(:view_issues)
62 62 if tracker_ids.any?
63 63 "#{Tracker.table_name}.id IN (#{tracker_ids.join(',')})"
64 64 else
65 65 '1=0'
66 66 end
67 67 end
68 68 end
69 joins(:projects).where(condition).uniq
69 joins(:projects).where(condition).distinct
70 70 }
71 71
72 72 def to_s; name end
73 73
74 74 def <=>(tracker)
75 75 position <=> tracker.position
76 76 end
77 77
78 78 # Returns an array of IssueStatus that are used
79 79 # in the tracker's workflows
80 80 def issue_statuses
81 81 @issue_statuses ||= IssueStatus.where(:id => issue_status_ids).to_a.sort
82 82 end
83 83
84 84 def issue_status_ids
85 85 if new_record?
86 86 []
87 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 89 end
90 90 end
91 91
92 92 def disabled_core_fields
93 93 i = -1
94 94 @disabled_core_fields ||= CORE_FIELDS.select { i += 1; (fields_bits || 0) & (2 ** i) != 0}
95 95 end
96 96
97 97 def core_fields
98 98 CORE_FIELDS - disabled_core_fields
99 99 end
100 100
101 101 def core_fields=(fields)
102 102 raise ArgumentError.new("Tracker.core_fields takes an array") unless fields.is_a?(Array)
103 103
104 104 bits = 0
105 105 CORE_FIELDS.each_with_index do |field, i|
106 106 unless fields.include?(field)
107 107 bits |= 2 ** i
108 108 end
109 109 end
110 110 self.fields_bits = bits
111 111 @disabled_core_fields = nil
112 112 core_fields
113 113 end
114 114
115 115 # Returns the fields that are disabled for all the given trackers
116 116 def self.disabled_core_fields(trackers)
117 117 if trackers.present?
118 118 trackers.map(&:disabled_core_fields).reduce(:&)
119 119 else
120 120 []
121 121 end
122 122 end
123 123
124 124 # Returns the fields that are enabled for one tracker at least
125 125 def self.core_fields(trackers)
126 126 if trackers.present?
127 127 trackers.uniq.map(&:core_fields).reduce(:|)
128 128 else
129 129 CORE_FIELDS.dup
130 130 end
131 131 end
132 132
133 133 private
134 134 def check_integrity
135 135 raise Exception.new("Cannot delete tracker") if Issue.where(:tracker_id => self.id).any?
136 136 end
137 137 end
@@ -1,923 +1,923
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Different ways of displaying/sorting users
24 24 USER_FORMATS = {
25 25 :firstname_lastname => {
26 26 :string => '#{firstname} #{lastname}',
27 27 :order => %w(firstname lastname id),
28 28 :setting_order => 1
29 29 },
30 30 :firstname_lastinitial => {
31 31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
32 32 :order => %w(firstname lastname id),
33 33 :setting_order => 2
34 34 },
35 35 :firstinitial_lastname => {
36 36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
37 37 :order => %w(firstname lastname id),
38 38 :setting_order => 2
39 39 },
40 40 :firstname => {
41 41 :string => '#{firstname}',
42 42 :order => %w(firstname id),
43 43 :setting_order => 3
44 44 },
45 45 :lastname_firstname => {
46 46 :string => '#{lastname} #{firstname}',
47 47 :order => %w(lastname firstname id),
48 48 :setting_order => 4
49 49 },
50 50 :lastnamefirstname => {
51 51 :string => '#{lastname}#{firstname}',
52 52 :order => %w(lastname firstname id),
53 53 :setting_order => 5
54 54 },
55 55 :lastname_comma_firstname => {
56 56 :string => '#{lastname}, #{firstname}',
57 57 :order => %w(lastname firstname id),
58 58 :setting_order => 6
59 59 },
60 60 :lastname => {
61 61 :string => '#{lastname}',
62 62 :order => %w(lastname id),
63 63 :setting_order => 7
64 64 },
65 65 :username => {
66 66 :string => '#{login}',
67 67 :order => %w(login id),
68 68 :setting_order => 8
69 69 },
70 70 }
71 71
72 72 MAIL_NOTIFICATION_OPTIONS = [
73 73 ['all', :label_user_mail_option_all],
74 74 ['selected', :label_user_mail_option_selected],
75 75 ['only_my_events', :label_user_mail_option_only_my_events],
76 76 ['only_assigned', :label_user_mail_option_only_assigned],
77 77 ['only_owner', :label_user_mail_option_only_owner],
78 78 ['none', :label_user_mail_option_none]
79 79 ]
80 80
81 81 has_and_belongs_to_many :groups,
82 82 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
83 83 :after_add => Proc.new {|user, group| group.user_added(user)},
84 84 :after_remove => Proc.new {|user, group| group.user_removed(user)}
85 85 has_many :changesets, :dependent => :nullify
86 86 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
87 87 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
88 88 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
89 89 has_one :email_address, lambda {where :is_default => true}, :autosave => true
90 90 has_many :email_addresses, :dependent => :delete_all
91 91 belongs_to :auth_source
92 92
93 93 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
94 94 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
95 95
96 96 acts_as_customizable
97 97
98 98 attr_accessor :password, :password_confirmation, :generate_password
99 99 attr_accessor :last_before_login_on
100 100 attr_accessor :remote_ip
101 101
102 102 # Prevents unauthorized assignments
103 103 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
104 104
105 105 LOGIN_LENGTH_LIMIT = 60
106 106 MAIL_LENGTH_LIMIT = 60
107 107
108 108 validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
109 109 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
110 110 # Login must contain letters, numbers, underscores only
111 111 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
112 112 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
113 113 validates_length_of :firstname, :lastname, :maximum => 30
114 114 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
115 115 validate :validate_password_length
116 116 validate do
117 117 if password_confirmation && password != password_confirmation
118 118 errors.add(:password, :confirmation)
119 119 end
120 120 end
121 121
122 122 self.valid_statuses = [STATUS_ACTIVE, STATUS_REGISTERED, STATUS_LOCKED]
123 123
124 124 before_validation :instantiate_email_address
125 125 before_create :set_mail_notification
126 126 before_save :generate_password_if_needed, :update_hashed_password
127 127 before_destroy :remove_references_before_destroy
128 128 after_save :update_notified_project_ids, :destroy_tokens, :deliver_security_notification
129 129 after_destroy :deliver_security_notification
130 130
131 131 scope :in_group, lambda {|group|
132 132 group_id = group.is_a?(Group) ? group.id : group.to_i
133 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 135 scope :not_in_group, lambda {|group|
136 136 group_id = group.is_a?(Group) ? group.id : group.to_i
137 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 139 scope :sorted, lambda { order(*User.fields_for_order_statement)}
140 140 scope :having_mail, lambda {|arg|
141 141 addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
142 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 144 else
145 145 none
146 146 end
147 147 }
148 148
149 149 def set_mail_notification
150 150 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
151 151 true
152 152 end
153 153
154 154 def update_hashed_password
155 155 # update hashed_password if password was set
156 156 if self.password && self.auth_source_id.blank?
157 157 salt_password(password)
158 158 end
159 159 end
160 160
161 161 alias :base_reload :reload
162 162 def reload(*args)
163 163 @name = nil
164 164 @projects_by_role = nil
165 165 @membership_by_project_id = nil
166 166 @notified_projects_ids = nil
167 167 @notified_projects_ids_changed = false
168 168 @builtin_role = nil
169 169 @visible_project_ids = nil
170 170 @managed_roles = nil
171 171 base_reload(*args)
172 172 end
173 173
174 174 def mail
175 175 email_address.try(:address)
176 176 end
177 177
178 178 def mail=(arg)
179 179 email = email_address || build_email_address
180 180 email.address = arg
181 181 end
182 182
183 183 def mail_changed?
184 184 email_address.try(:address_changed?)
185 185 end
186 186
187 187 def mails
188 188 email_addresses.pluck(:address)
189 189 end
190 190
191 191 def self.find_or_initialize_by_identity_url(url)
192 192 user = where(:identity_url => url).first
193 193 unless user
194 194 user = User.new
195 195 user.identity_url = url
196 196 end
197 197 user
198 198 end
199 199
200 200 def identity_url=(url)
201 201 if url.blank?
202 202 write_attribute(:identity_url, '')
203 203 else
204 204 begin
205 205 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
206 206 rescue OpenIdAuthentication::InvalidOpenId
207 207 # Invalid url, don't save
208 208 end
209 209 end
210 210 self.read_attribute(:identity_url)
211 211 end
212 212
213 213 # Returns the user that matches provided login and password, or nil
214 214 def self.try_to_login(login, password, active_only=true)
215 215 login = login.to_s
216 216 password = password.to_s
217 217
218 218 # Make sure no one can sign in with an empty login or password
219 219 return nil if login.empty? || password.empty?
220 220 user = find_by_login(login)
221 221 if user
222 222 # user is already in local database
223 223 return nil unless user.check_password?(password)
224 224 return nil if !user.active? && active_only
225 225 else
226 226 # user is not yet registered, try to authenticate with available sources
227 227 attrs = AuthSource.authenticate(login, password)
228 228 if attrs
229 229 user = new(attrs)
230 230 user.login = login
231 231 user.language = Setting.default_language
232 232 if user.save
233 233 user.reload
234 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 235 end
236 236 end
237 237 end
238 238 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
239 239 user
240 240 rescue => text
241 241 raise text
242 242 end
243 243
244 244 # Returns the user who matches the given autologin +key+ or nil
245 245 def self.try_to_autologin(key)
246 246 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
247 247 if user
248 248 user.update_column(:last_login_on, Time.now)
249 249 user
250 250 end
251 251 end
252 252
253 253 def self.name_formatter(formatter = nil)
254 254 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
255 255 end
256 256
257 257 # Returns an array of fields names than can be used to make an order statement for users
258 258 # according to how user names are displayed
259 259 # Examples:
260 260 #
261 261 # User.fields_for_order_statement => ['users.login', 'users.id']
262 262 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
263 263 def self.fields_for_order_statement(table=nil)
264 264 table ||= table_name
265 265 name_formatter[:order].map {|field| "#{table}.#{field}"}
266 266 end
267 267
268 268 # Return user's full name for display
269 269 def name(formatter = nil)
270 270 f = self.class.name_formatter(formatter)
271 271 if formatter
272 272 eval('"' + f[:string] + '"')
273 273 else
274 274 @name ||= eval('"' + f[:string] + '"')
275 275 end
276 276 end
277 277
278 278 def active?
279 279 self.status == STATUS_ACTIVE
280 280 end
281 281
282 282 def registered?
283 283 self.status == STATUS_REGISTERED
284 284 end
285 285
286 286 def locked?
287 287 self.status == STATUS_LOCKED
288 288 end
289 289
290 290 def activate
291 291 self.status = STATUS_ACTIVE
292 292 end
293 293
294 294 def register
295 295 self.status = STATUS_REGISTERED
296 296 end
297 297
298 298 def lock
299 299 self.status = STATUS_LOCKED
300 300 end
301 301
302 302 def activate!
303 303 update_attribute(:status, STATUS_ACTIVE)
304 304 end
305 305
306 306 def register!
307 307 update_attribute(:status, STATUS_REGISTERED)
308 308 end
309 309
310 310 def lock!
311 311 update_attribute(:status, STATUS_LOCKED)
312 312 end
313 313
314 314 # Returns true if +clear_password+ is the correct user's password, otherwise false
315 315 def check_password?(clear_password)
316 316 if auth_source_id.present?
317 317 auth_source.authenticate(self.login, clear_password)
318 318 else
319 319 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
320 320 end
321 321 end
322 322
323 323 # Generates a random salt and computes hashed_password for +clear_password+
324 324 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
325 325 def salt_password(clear_password)
326 326 self.salt = User.generate_salt
327 327 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
328 328 self.passwd_changed_on = Time.now.change(:usec => 0)
329 329 end
330 330
331 331 # Does the backend storage allow this user to change their password?
332 332 def change_password_allowed?
333 333 return true if auth_source.nil?
334 334 return auth_source.allow_password_changes?
335 335 end
336 336
337 337 # Returns true if the user password has expired
338 338 def password_expired?
339 339 period = Setting.password_max_age.to_i
340 340 if period.zero?
341 341 false
342 342 else
343 343 changed_on = self.passwd_changed_on || Time.at(0)
344 344 changed_on < period.days.ago
345 345 end
346 346 end
347 347
348 348 def must_change_password?
349 349 (must_change_passwd? || password_expired?) && change_password_allowed?
350 350 end
351 351
352 352 def generate_password?
353 353 generate_password == '1' || generate_password == true
354 354 end
355 355
356 356 # Generate and set a random password on given length
357 357 def random_password(length=40)
358 358 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
359 359 chars -= %w(0 O 1 l)
360 360 password = ''
361 361 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
362 362 self.password = password
363 363 self.password_confirmation = password
364 364 self
365 365 end
366 366
367 367 def pref
368 368 self.preference ||= UserPreference.new(:user => self)
369 369 end
370 370
371 371 def time_zone
372 372 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
373 373 end
374 374
375 375 def force_default_language?
376 376 Setting.force_default_language_for_loggedin?
377 377 end
378 378
379 379 def language
380 380 if force_default_language?
381 381 Setting.default_language
382 382 else
383 383 super
384 384 end
385 385 end
386 386
387 387 def wants_comments_in_reverse_order?
388 388 self.pref[:comments_sorting] == 'desc'
389 389 end
390 390
391 391 # Return user's RSS key (a 40 chars long string), used to access feeds
392 392 def rss_key
393 393 if rss_token.nil?
394 394 create_rss_token(:action => 'feeds')
395 395 end
396 396 rss_token.value
397 397 end
398 398
399 399 # Return user's API key (a 40 chars long string), used to access the API
400 400 def api_key
401 401 if api_token.nil?
402 402 create_api_token(:action => 'api')
403 403 end
404 404 api_token.value
405 405 end
406 406
407 407 # Generates a new session token and returns its value
408 408 def generate_session_token
409 409 token = Token.create!(:user_id => id, :action => 'session')
410 410 token.value
411 411 end
412 412
413 413 # Returns true if token is a valid session token for the user whose id is user_id
414 414 def self.verify_session_token(user_id, token)
415 415 return false if user_id.blank? || token.blank?
416 416
417 417 scope = Token.where(:user_id => user_id, :value => token.to_s, :action => 'session')
418 418 if Setting.session_lifetime?
419 419 scope = scope.where("created_on > ?", Setting.session_lifetime.to_i.minutes.ago)
420 420 end
421 421 if Setting.session_timeout?
422 422 scope = scope.where("updated_on > ?", Setting.session_timeout.to_i.minutes.ago)
423 423 end
424 424 scope.update_all(:updated_on => Time.now) == 1
425 425 end
426 426
427 427 # Return an array of project ids for which the user has explicitly turned mail notifications on
428 428 def notified_projects_ids
429 429 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
430 430 end
431 431
432 432 def notified_project_ids=(ids)
433 433 @notified_projects_ids_changed = true
434 434 @notified_projects_ids = ids.map(&:to_i).uniq.select {|n| n > 0}
435 435 end
436 436
437 437 # Updates per project notifications (after_save callback)
438 438 def update_notified_project_ids
439 439 if @notified_projects_ids_changed
440 440 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
441 441 members.update_all(:mail_notification => false)
442 442 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
443 443 end
444 444 end
445 445 private :update_notified_project_ids
446 446
447 447 def valid_notification_options
448 448 self.class.valid_notification_options(self)
449 449 end
450 450
451 451 # Only users that belong to more than 1 project can select projects for which they are notified
452 452 def self.valid_notification_options(user=nil)
453 453 # Note that @user.membership.size would fail since AR ignores
454 454 # :include association option when doing a count
455 455 if user.nil? || user.memberships.length < 1
456 456 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
457 457 else
458 458 MAIL_NOTIFICATION_OPTIONS
459 459 end
460 460 end
461 461
462 462 # Find a user account by matching the exact login and then a case-insensitive
463 463 # version. Exact matches will be given priority.
464 464 def self.find_by_login(login)
465 465 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
466 466 if login.present?
467 467 # First look for an exact match
468 468 user = where(:login => login).detect {|u| u.login == login}
469 469 unless user
470 470 # Fail over to case-insensitive if none was found
471 471 user = where("LOWER(login) = ?", login.downcase).first
472 472 end
473 473 user
474 474 end
475 475 end
476 476
477 477 def self.find_by_rss_key(key)
478 478 Token.find_active_user('feeds', key)
479 479 end
480 480
481 481 def self.find_by_api_key(key)
482 482 Token.find_active_user('api', key)
483 483 end
484 484
485 485 # Makes find_by_mail case-insensitive
486 486 def self.find_by_mail(mail)
487 487 having_mail(mail).first
488 488 end
489 489
490 490 # Returns true if the default admin account can no longer be used
491 491 def self.default_admin_account_changed?
492 492 !User.active.find_by_login("admin").try(:check_password?, "admin")
493 493 end
494 494
495 495 def to_s
496 496 name
497 497 end
498 498
499 499 CSS_CLASS_BY_STATUS = {
500 500 STATUS_ANONYMOUS => 'anon',
501 501 STATUS_ACTIVE => 'active',
502 502 STATUS_REGISTERED => 'registered',
503 503 STATUS_LOCKED => 'locked'
504 504 }
505 505
506 506 def css_classes
507 507 "user #{CSS_CLASS_BY_STATUS[status]}"
508 508 end
509 509
510 510 # Returns the current day according to user's time zone
511 511 def today
512 512 if time_zone.nil?
513 513 Date.today
514 514 else
515 515 time_zone.today
516 516 end
517 517 end
518 518
519 519 # Returns the day of +time+ according to user's time zone
520 520 def time_to_date(time)
521 521 if time_zone.nil?
522 522 time.to_date
523 523 else
524 524 time.in_time_zone(time_zone).to_date
525 525 end
526 526 end
527 527
528 528 def logged?
529 529 true
530 530 end
531 531
532 532 def anonymous?
533 533 !logged?
534 534 end
535 535
536 536 # Returns user's membership for the given project
537 537 # or nil if the user is not a member of project
538 538 def membership(project)
539 539 project_id = project.is_a?(Project) ? project.id : project
540 540
541 541 @membership_by_project_id ||= Hash.new {|h, project_id|
542 542 h[project_id] = memberships.where(:project_id => project_id).first
543 543 }
544 544 @membership_by_project_id[project_id]
545 545 end
546 546
547 547 # Returns the user's bult-in role
548 548 def builtin_role
549 549 @builtin_role ||= Role.non_member
550 550 end
551 551
552 552 # Return user's roles for project
553 553 def roles_for_project(project)
554 554 # No role on archived projects
555 555 return [] if project.nil? || project.archived?
556 556 if membership = membership(project)
557 557 membership.roles.to_a
558 558 elsif project.is_public?
559 559 project.override_roles(builtin_role)
560 560 else
561 561 []
562 562 end
563 563 end
564 564
565 565 # Returns a hash of user's projects grouped by roles
566 566 def projects_by_role
567 567 return @projects_by_role if @projects_by_role
568 568
569 569 hash = Hash.new([])
570 570
571 571 group_class = anonymous? ? GroupAnonymous : GroupNonMember
572 572 members = Member.joins(:project, :principal).
573 573 where("#{Project.table_name}.status <> 9").
574 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 575 preload(:project, :roles).
576 576 to_a
577 577
578 578 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
579 579 members.each do |member|
580 580 if member.project
581 581 member.roles.each do |role|
582 582 hash[role] = [] unless hash.key?(role)
583 583 hash[role] << member.project
584 584 end
585 585 end
586 586 end
587 587
588 588 hash.each do |role, projects|
589 589 projects.uniq!
590 590 end
591 591
592 592 @projects_by_role = hash
593 593 end
594 594
595 595 # Returns the ids of visible projects
596 596 def visible_project_ids
597 597 @visible_project_ids ||= Project.visible(self).pluck(:id)
598 598 end
599 599
600 600 # Returns the roles that the user is allowed to manage for the given project
601 601 def managed_roles(project)
602 602 if admin?
603 603 @managed_roles ||= Role.givable.to_a
604 604 else
605 605 membership(project).try(:managed_roles) || []
606 606 end
607 607 end
608 608
609 609 # Returns true if user is arg or belongs to arg
610 610 def is_or_belongs_to?(arg)
611 611 if arg.is_a?(User)
612 612 self == arg
613 613 elsif arg.is_a?(Group)
614 614 arg.users.include?(self)
615 615 else
616 616 false
617 617 end
618 618 end
619 619
620 620 # Return true if the user is allowed to do the specified action on a specific context
621 621 # Action can be:
622 622 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
623 623 # * a permission Symbol (eg. :edit_project)
624 624 # Context can be:
625 625 # * a project : returns true if user is allowed to do the specified action on this project
626 626 # * an array of projects : returns true if user is allowed on every project
627 627 # * nil with options[:global] set : check if user has at least one role allowed for this action,
628 628 # or falls back to Non Member / Anonymous permissions depending if the user is logged
629 629 def allowed_to?(action, context, options={}, &block)
630 630 if context && context.is_a?(Project)
631 631 return false unless context.allows_to?(action)
632 632 # Admin users are authorized for anything else
633 633 return true if admin?
634 634
635 635 roles = roles_for_project(context)
636 636 return false unless roles
637 637 roles.any? {|role|
638 638 (context.is_public? || role.member?) &&
639 639 role.allowed_to?(action) &&
640 640 (block_given? ? yield(role, self) : true)
641 641 }
642 642 elsif context && context.is_a?(Array)
643 643 if context.empty?
644 644 false
645 645 else
646 646 # Authorize if user is authorized on every element of the array
647 647 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
648 648 end
649 649 elsif context
650 650 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
651 651 elsif options[:global]
652 652 # Admin users are always authorized
653 653 return true if admin?
654 654
655 655 # authorize if user has at least one role that has this permission
656 656 roles = memberships.collect {|m| m.roles}.flatten.uniq
657 657 roles << (self.logged? ? Role.non_member : Role.anonymous)
658 658 roles.any? {|role|
659 659 role.allowed_to?(action) &&
660 660 (block_given? ? yield(role, self) : true)
661 661 }
662 662 else
663 663 false
664 664 end
665 665 end
666 666
667 667 # Is the user allowed to do the specified action on any project?
668 668 # See allowed_to? for the actions and valid options.
669 669 #
670 670 # NB: this method is not used anywhere in the core codebase as of
671 671 # 2.5.2, but it's used by many plugins so if we ever want to remove
672 672 # it it has to be carefully deprecated for a version or two.
673 673 def allowed_to_globally?(action, options={}, &block)
674 674 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
675 675 end
676 676
677 677 def allowed_to_view_all_time_entries?(context)
678 678 allowed_to?(:view_time_entries, context) do |role, user|
679 679 role.time_entries_visibility == 'all'
680 680 end
681 681 end
682 682
683 683 # Returns true if the user is allowed to delete the user's own account
684 684 def own_account_deletable?
685 685 Setting.unsubscribe? &&
686 686 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
687 687 end
688 688
689 689 safe_attributes 'firstname',
690 690 'lastname',
691 691 'mail',
692 692 'mail_notification',
693 693 'notified_project_ids',
694 694 'language',
695 695 'custom_field_values',
696 696 'custom_fields',
697 697 'identity_url'
698 698
699 699 safe_attributes 'status',
700 700 'auth_source_id',
701 701 'generate_password',
702 702 'must_change_passwd',
703 703 :if => lambda {|user, current_user| current_user.admin?}
704 704
705 705 safe_attributes 'group_ids',
706 706 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
707 707
708 708 # Utility method to help check if a user should be notified about an
709 709 # event.
710 710 #
711 711 # TODO: only supports Issue events currently
712 712 def notify_about?(object)
713 713 if mail_notification == 'all'
714 714 true
715 715 elsif mail_notification.blank? || mail_notification == 'none'
716 716 false
717 717 else
718 718 case object
719 719 when Issue
720 720 case mail_notification
721 721 when 'selected', 'only_my_events'
722 722 # user receives notifications for created/assigned issues on unselected projects
723 723 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
724 724 when 'only_assigned'
725 725 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
726 726 when 'only_owner'
727 727 object.author == self
728 728 end
729 729 when News
730 730 # always send to project members except when mail_notification is set to 'none'
731 731 true
732 732 end
733 733 end
734 734 end
735 735
736 736 def self.current=(user)
737 737 RequestStore.store[:current_user] = user
738 738 end
739 739
740 740 def self.current
741 741 RequestStore.store[:current_user] ||= User.anonymous
742 742 end
743 743
744 744 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
745 745 # one anonymous user per database.
746 746 def self.anonymous
747 747 anonymous_user = AnonymousUser.first
748 748 if anonymous_user.nil?
749 749 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
750 750 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
751 751 end
752 752 anonymous_user
753 753 end
754 754
755 755 # Salts all existing unsalted passwords
756 756 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
757 757 # This method is used in the SaltPasswords migration and is to be kept as is
758 758 def self.salt_unsalted_passwords!
759 759 transaction do
760 760 User.where("salt IS NULL OR salt = ''").find_each do |user|
761 761 next if user.hashed_password.blank?
762 762 salt = User.generate_salt
763 763 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
764 764 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
765 765 end
766 766 end
767 767 end
768 768
769 769 protected
770 770
771 771 def validate_password_length
772 772 return if password.blank? && generate_password?
773 773 # Password length validation based on setting
774 774 if !password.nil? && password.size < Setting.password_min_length.to_i
775 775 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
776 776 end
777 777 end
778 778
779 779 def instantiate_email_address
780 780 email_address || build_email_address
781 781 end
782 782
783 783 private
784 784
785 785 def generate_password_if_needed
786 786 if generate_password? && auth_source.nil?
787 787 length = [Setting.password_min_length.to_i + 2, 10].max
788 788 random_password(length)
789 789 end
790 790 end
791 791
792 792 # Delete all outstanding password reset tokens on password change.
793 793 # Delete the autologin tokens on password change to prohibit session leakage.
794 794 # This helps to keep the account secure in case the associated email account
795 795 # was compromised.
796 796 def destroy_tokens
797 797 if hashed_password_changed? || (status_changed? && !active?)
798 798 tokens = ['recovery', 'autologin', 'session']
799 799 Token.where(:user_id => id, :action => tokens).delete_all
800 800 end
801 801 end
802 802
803 803 # Removes references that are not handled by associations
804 804 # Things that are not deleted are reassociated with the anonymous user
805 805 def remove_references_before_destroy
806 806 return if self.id.nil?
807 807
808 808 substitute = User.anonymous
809 809 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
810 810 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
811 811 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
812 812 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
813 813 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
814 814 JournalDetail.
815 815 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
816 816 update_all(['old_value = ?', substitute.id.to_s])
817 817 JournalDetail.
818 818 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
819 819 update_all(['value = ?', substitute.id.to_s])
820 820 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
821 821 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
822 822 # Remove private queries and keep public ones
823 823 ::Query.delete_all ['user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE]
824 824 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
825 825 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
826 826 Token.delete_all ['user_id = ?', id]
827 827 Watcher.delete_all ['user_id = ?', id]
828 828 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
829 829 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
830 830 end
831 831
832 832 # Return password digest
833 833 def self.hash_password(clear_password)
834 834 Digest::SHA1.hexdigest(clear_password || "")
835 835 end
836 836
837 837 # Returns a 128bits random salt as a hex string (32 chars long)
838 838 def self.generate_salt
839 839 Redmine::Utils.random_hex(16)
840 840 end
841 841
842 842 # Send a security notification to all admins if the user has gained/lost admin privileges
843 843 def deliver_security_notification
844 844 options = {
845 845 field: :field_admin,
846 846 value: login,
847 847 title: :label_user_plural,
848 848 url: {controller: 'users', action: 'index'}
849 849 }
850 850
851 851 deliver = false
852 852 if (admin? && id_changed? && active?) || # newly created admin
853 853 (admin? && admin_changed? && active?) || # regular user became admin
854 854 (admin? && status_changed? && active?) # locked admin became active again
855 855
856 856 deliver = true
857 857 options[:message] = :mail_body_security_notification_add
858 858
859 859 elsif (admin? && destroyed? && active?) || # active admin user was deleted
860 860 (!admin? && admin_changed? && active?) || # admin is no longer admin
861 861 (admin? && status_changed? && !active?) # admin was locked
862 862
863 863 deliver = true
864 864 options[:message] = :mail_body_security_notification_remove
865 865 end
866 866
867 867 if deliver
868 868 users = User.active.where(admin: true).to_a
869 869 Mailer.security_notification(users, options).deliver
870 870 end
871 871 end
872 872 end
873 873
874 874 class AnonymousUser < User
875 875 validate :validate_anonymous_uniqueness, :on => :create
876 876
877 877 self.valid_statuses = [STATUS_ANONYMOUS]
878 878
879 879 def validate_anonymous_uniqueness
880 880 # There should be only one AnonymousUser in the database
881 881 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
882 882 end
883 883
884 884 def available_custom_fields
885 885 []
886 886 end
887 887
888 888 # Overrides a few properties
889 889 def logged?; false end
890 890 def admin; false end
891 891 def name(*args); I18n.t(:label_user_anonymous) end
892 892 def mail=(*args); nil end
893 893 def mail; nil end
894 894 def time_zone; nil end
895 895 def rss_key; nil end
896 896
897 897 def pref
898 898 UserPreference.new(:user => self)
899 899 end
900 900
901 901 # Returns the user's bult-in role
902 902 def builtin_role
903 903 @builtin_role ||= Role.anonymous
904 904 end
905 905
906 906 def membership(*args)
907 907 nil
908 908 end
909 909
910 910 def member_of?(*args)
911 911 false
912 912 end
913 913
914 914 # Anonymous user can not be destroyed
915 915 def destroy
916 916 false
917 917 end
918 918
919 919 protected
920 920
921 921 def instantiate_email_address
922 922 end
923 923 end
@@ -1,217 +1,217
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module Redmine
19 19 module Acts
20 20 module Searchable
21 21 def self.included(base)
22 22 base.extend ClassMethods
23 23 end
24 24
25 25 module ClassMethods
26 26 # Adds the search methods to the class.
27 27 #
28 28 # Options:
29 29 # * :columns - a column or an array of columns to search
30 30 # * :project_key - project foreign key (default to project_id)
31 31 # * :date_column - name of the datetime column used to sort results (default to :created_on)
32 32 # * :permission - permission required to search the model
33 33 # * :scope - scope used to search results
34 34 # * :preload - associations to preload when loading results for display
35 35 def acts_as_searchable(options = {})
36 36 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
37 37 options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
38 38
39 39 cattr_accessor :searchable_options
40 40 self.searchable_options = options
41 41
42 42 if searchable_options[:columns].nil?
43 43 raise 'No searchable column defined.'
44 44 elsif !searchable_options[:columns].is_a?(Array)
45 45 searchable_options[:columns] = [] << searchable_options[:columns]
46 46 end
47 47
48 48 searchable_options[:project_key] ||= "#{table_name}.project_id"
49 49 searchable_options[:date_column] ||= :created_on
50 50
51 51 # Should we search additional associations on this model ?
52 52 searchable_options[:search_custom_fields] = reflect_on_association(:custom_values).present?
53 53 searchable_options[:search_attachments] = reflect_on_association(:attachments).present?
54 54 searchable_options[:search_journals] = reflect_on_association(:journals).present?
55 55
56 56 send :include, Redmine::Acts::Searchable::InstanceMethods
57 57 end
58 58 end
59 59
60 60 module InstanceMethods
61 61 def self.included(base)
62 62 base.extend ClassMethods
63 63 end
64 64
65 65 module ClassMethods
66 66 # Searches the model for the given tokens and user visibility.
67 67 # The projects argument can be either nil (will search all projects), a project or an array of projects.
68 68 # Returns an array that contains the rank and id of all results.
69 69 # In current implementation, the rank is the record timestamp converted as an integer.
70 70 #
71 71 # Valid options:
72 72 # * :titles_only - searches tokens in the first searchable column only
73 73 # * :all_words - searches results that match all token
74 74 # * :
75 75 # * :limit - maximum number of results to return
76 76 #
77 77 # Example:
78 78 # Issue.search_result_ranks_and_ids("foo")
79 79 # # => [[1419595329, 69], [1419595622, 123]]
80 80 def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
81 81 tokens = [] << tokens unless tokens.is_a?(Array)
82 82 projects = [] << projects if projects.is_a?(Project)
83 83
84 84 columns = searchable_options[:columns]
85 85 columns = columns[0..0] if options[:titles_only]
86 86
87 87 r = []
88 88 queries = 0
89 89
90 90 unless options[:attachments] == 'only'
91 91 r = fetch_ranks_and_ids(
92 92 search_scope(user, projects, options).
93 93 where(search_tokens_condition(columns, tokens, options[:all_words])),
94 94 options[:limit]
95 95 )
96 96 queries += 1
97 97
98 98 if !options[:titles_only] && searchable_options[:search_custom_fields]
99 99 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true).to_a
100 100
101 101 if searchable_custom_fields.any?
102 102 fields_by_visibility = searchable_custom_fields.group_by {|field|
103 103 field.visibility_by_project_condition(searchable_options[:project_key], user, "#{CustomValue.table_name}.custom_field_id")
104 104 }
105 105 clauses = []
106 106 fields_by_visibility.each do |visibility, fields|
107 107 clauses << "(#{CustomValue.table_name}.custom_field_id IN (#{fields.map(&:id).join(',')}) AND (#{visibility}))"
108 108 end
109 109 visibility = clauses.join(' OR ')
110 110
111 111 r |= fetch_ranks_and_ids(
112 112 search_scope(user, projects, options).
113 113 joins(:custom_values).
114 114 where(visibility).
115 115 where(search_tokens_condition(["#{CustomValue.table_name}.value"], tokens, options[:all_words])),
116 116 options[:limit]
117 117 )
118 118 queries += 1
119 119 end
120 120 end
121 121
122 122 if !options[:titles_only] && searchable_options[:search_journals]
123 123 r |= fetch_ranks_and_ids(
124 124 search_scope(user, projects, options).
125 125 joins(:journals).
126 126 where("#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(user, :view_private_notes)})", false).
127 127 where(search_tokens_condition(["#{Journal.table_name}.notes"], tokens, options[:all_words])),
128 128 options[:limit]
129 129 )
130 130 queries += 1
131 131 end
132 132 end
133 133
134 134 if searchable_options[:search_attachments] && (options[:titles_only] ? options[:attachments] == 'only' : options[:attachments] != '0')
135 135 r |= fetch_ranks_and_ids(
136 136 search_scope(user, projects, options).
137 137 joins(:attachments).
138 138 where(search_tokens_condition(["#{Attachment.table_name}.filename", "#{Attachment.table_name}.description"], tokens, options[:all_words])),
139 139 options[:limit]
140 140 )
141 141 queries += 1
142 142 end
143 143
144 144 if queries > 1
145 145 r = r.sort.reverse
146 146 if options[:limit] && r.size > options[:limit]
147 147 r = r[0, options[:limit]]
148 148 end
149 149 end
150 150
151 151 r
152 152 end
153 153
154 154 def search_tokens_condition(columns, tokens, all_words)
155 155 token_clauses = columns.map {|column| "(#{search_token_match_statement(column)})"}
156 156 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(all_words ? ' AND ' : ' OR ')
157 157 [sql, * (tokens.collect {|w| "%#{w}%"} * token_clauses.size).sort]
158 158 end
159 159 private :search_tokens_condition
160 160
161 161 def search_token_match_statement(column, value='?')
162 162 Redmine::Database.like(column, value)
163 163 end
164 164 private :search_token_match_statement
165 165
166 166 def fetch_ranks_and_ids(scope, limit)
167 167 scope.
168 168 reorder(searchable_options[:date_column] => :desc, :id => :desc).
169 169 limit(limit).
170 uniq.
170 distinct.
171 171 pluck(searchable_options[:date_column], :id).
172 172 # converts timestamps to integers for faster sort
173 173 map {|timestamp, id| [timestamp.to_i, id]}
174 174 end
175 175 private :fetch_ranks_and_ids
176 176
177 177 # Returns the search scope for user and projects
178 178 def search_scope(user, projects, options={})
179 179 if projects.is_a?(Array) && projects.empty?
180 180 # no results
181 181 return none
182 182 end
183 183
184 184 scope = (searchable_options[:scope] || self)
185 185 if scope.is_a? Proc
186 186 scope = scope.call(options)
187 187 end
188 188
189 189 if respond_to?(:visible) && !searchable_options.has_key?(:permission)
190 190 scope = scope.visible(user)
191 191 else
192 192 permission = searchable_options[:permission] || :view_project
193 193 scope = scope.where(Project.allowed_to_condition(user, permission))
194 194 end
195 195
196 196 if projects
197 197 scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
198 198 end
199 199 scope
200 200 end
201 201 private :search_scope
202 202
203 203 # Returns search results of given ids
204 204 def search_results_from_ids(ids)
205 205 where(:id => ids).preload(searchable_options[:preload]).to_a
206 206 end
207 207
208 208 # Returns search results with same arguments as search_result_ranks_and_ids
209 209 def search_results(*args)
210 210 ranks_and_ids = search_result_ranks_and_ids(*args)
211 211 search_results_from_ids(ranks_and_ids.map(&:last))
212 212 end
213 213 end
214 214 end
215 215 end
216 216 end
217 217 end
@@ -1,954 +1,954
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module Redmine
19 19 module Helpers
20 20 # Simple class to handle gantt chart data
21 21 class Gantt
22 22 class MaxLinesLimitReached < Exception
23 23 end
24 24
25 25 include ERB::Util
26 26 include Redmine::I18n
27 27 include Redmine::Utils::DateCalculation
28 28
29 29 # Relation types that are rendered
30 30 DRAW_TYPES = {
31 31 IssueRelation::TYPE_BLOCKS => { :landscape_margin => 16, :color => '#F34F4F' },
32 32 IssueRelation::TYPE_PRECEDES => { :landscape_margin => 20, :color => '#628FEA' }
33 33 }.freeze
34 34
35 35 # :nodoc:
36 36 # Some utility methods for the PDF export
37 37 class PDF
38 38 MaxCharactorsForSubject = 45
39 39 TotalWidth = 280
40 40 LeftPaneWidth = 100
41 41
42 42 def self.right_pane_width
43 43 TotalWidth - LeftPaneWidth
44 44 end
45 45 end
46 46
47 47 attr_reader :year_from, :month_from, :date_from, :date_to, :zoom, :months, :truncated, :max_rows
48 48 attr_accessor :query
49 49 attr_accessor :project
50 50 attr_accessor :view
51 51
52 52 def initialize(options={})
53 53 options = options.dup
54 54 if options[:year] && options[:year].to_i >0
55 55 @year_from = options[:year].to_i
56 56 if options[:month] && options[:month].to_i >=1 && options[:month].to_i <= 12
57 57 @month_from = options[:month].to_i
58 58 else
59 59 @month_from = 1
60 60 end
61 61 else
62 62 @month_from ||= User.current.today.month
63 63 @year_from ||= User.current.today.year
64 64 end
65 65 zoom = (options[:zoom] || User.current.pref[:gantt_zoom]).to_i
66 66 @zoom = (zoom > 0 && zoom < 5) ? zoom : 2
67 67 months = (options[:months] || User.current.pref[:gantt_months]).to_i
68 68 @months = (months > 0 && months < 25) ? months : 6
69 69 # Save gantt parameters as user preference (zoom and months count)
70 70 if (User.current.logged? && (@zoom != User.current.pref[:gantt_zoom] ||
71 71 @months != User.current.pref[:gantt_months]))
72 72 User.current.pref[:gantt_zoom], User.current.pref[:gantt_months] = @zoom, @months
73 73 User.current.preference.save
74 74 end
75 75 @date_from = Date.civil(@year_from, @month_from, 1)
76 76 @date_to = (@date_from >> @months) - 1
77 77 @subjects = ''
78 78 @lines = ''
79 79 @number_of_rows = nil
80 80 @truncated = false
81 81 if options.has_key?(:max_rows)
82 82 @max_rows = options[:max_rows]
83 83 else
84 84 @max_rows = Setting.gantt_items_limit.blank? ? nil : Setting.gantt_items_limit.to_i
85 85 end
86 86 end
87 87
88 88 def common_params
89 89 { :controller => 'gantts', :action => 'show', :project_id => @project }
90 90 end
91 91
92 92 def params
93 93 common_params.merge({:zoom => zoom, :year => year_from,
94 94 :month => month_from, :months => months})
95 95 end
96 96
97 97 def params_previous
98 98 common_params.merge({:year => (date_from << months).year,
99 99 :month => (date_from << months).month,
100 100 :zoom => zoom, :months => months})
101 101 end
102 102
103 103 def params_next
104 104 common_params.merge({:year => (date_from >> months).year,
105 105 :month => (date_from >> months).month,
106 106 :zoom => zoom, :months => months})
107 107 end
108 108
109 109 # Returns the number of rows that will be rendered on the Gantt chart
110 110 def number_of_rows
111 111 return @number_of_rows if @number_of_rows
112 112 rows = projects.inject(0) {|total, p| total += number_of_rows_on_project(p)}
113 113 rows > @max_rows ? @max_rows : rows
114 114 end
115 115
116 116 # Returns the number of rows that will be used to list a project on
117 117 # the Gantt chart. This will recurse for each subproject.
118 118 def number_of_rows_on_project(project)
119 119 return 0 unless projects.include?(project)
120 120 count = 1
121 121 count += project_issues(project).size
122 122 count += project_versions(project).size
123 123 count
124 124 end
125 125
126 126 # Renders the subjects of the Gantt chart, the left side.
127 127 def subjects(options={})
128 128 render(options.merge(:only => :subjects)) unless @subjects_rendered
129 129 @subjects
130 130 end
131 131
132 132 # Renders the lines of the Gantt chart, the right side
133 133 def lines(options={})
134 134 render(options.merge(:only => :lines)) unless @lines_rendered
135 135 @lines
136 136 end
137 137
138 138 # Returns issues that will be rendered
139 139 def issues
140 140 @issues ||= @query.issues(
141 141 :include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
142 142 :order => "#{Project.table_name}.lft ASC, #{Issue.table_name}.id ASC",
143 143 :limit => @max_rows
144 144 )
145 145 end
146 146
147 147 # Returns a hash of the relations between the issues that are present on the gantt
148 148 # and that should be displayed, grouped by issue ids.
149 149 def relations
150 150 return @relations if @relations
151 151 if issues.any?
152 152 issue_ids = issues.map(&:id)
153 153 @relations = IssueRelation.
154 154 where(:issue_from_id => issue_ids, :issue_to_id => issue_ids, :relation_type => DRAW_TYPES.keys).
155 155 group_by(&:issue_from_id)
156 156 else
157 157 @relations = {}
158 158 end
159 159 end
160 160
161 161 # Return all the project nodes that will be displayed
162 162 def projects
163 163 return @projects if @projects
164 164 ids = issues.collect(&:project).uniq.collect(&:id)
165 165 if ids.any?
166 166 # All issues projects and their visible ancestors
167 167 @projects = Project.visible.
168 168 joins("LEFT JOIN #{Project.table_name} child ON #{Project.table_name}.lft <= child.lft AND #{Project.table_name}.rgt >= child.rgt").
169 169 where("child.id IN (?)", ids).
170 170 order("#{Project.table_name}.lft ASC").
171 uniq.
171 distinct.
172 172 to_a
173 173 else
174 174 @projects = []
175 175 end
176 176 end
177 177
178 178 # Returns the issues that belong to +project+
179 179 def project_issues(project)
180 180 @issues_by_project ||= issues.group_by(&:project)
181 181 @issues_by_project[project] || []
182 182 end
183 183
184 184 # Returns the distinct versions of the issues that belong to +project+
185 185 def project_versions(project)
186 186 project_issues(project).collect(&:fixed_version).compact.uniq
187 187 end
188 188
189 189 # Returns the issues that belong to +project+ and are assigned to +version+
190 190 def version_issues(project, version)
191 191 project_issues(project).select {|issue| issue.fixed_version == version}
192 192 end
193 193
194 194 def render(options={})
195 195 options = {:top => 0, :top_increment => 20,
196 196 :indent_increment => 20, :render => :subject,
197 197 :format => :html}.merge(options)
198 198 indent = options[:indent] || 4
199 199 @subjects = '' unless options[:only] == :lines
200 200 @lines = '' unless options[:only] == :subjects
201 201 @number_of_rows = 0
202 202 begin
203 203 Project.project_tree(projects) do |project, level|
204 204 options[:indent] = indent + level * options[:indent_increment]
205 205 render_project(project, options)
206 206 end
207 207 rescue MaxLinesLimitReached
208 208 @truncated = true
209 209 end
210 210 @subjects_rendered = true unless options[:only] == :lines
211 211 @lines_rendered = true unless options[:only] == :subjects
212 212 render_end(options)
213 213 end
214 214
215 215 def render_project(project, options={})
216 216 render_object_row(project, options)
217 217 increment_indent(options) do
218 218 # render issue that are not assigned to a version
219 219 issues = project_issues(project).select {|i| i.fixed_version.nil?}
220 220 render_issues(issues, options)
221 221 # then render project versions and their issues
222 222 versions = project_versions(project)
223 223 self.class.sort_versions!(versions)
224 224 versions.each do |version|
225 225 render_version(project, version, options)
226 226 end
227 227 end
228 228 end
229 229
230 230 def render_version(project, version, options={})
231 231 render_object_row(version, options)
232 232 increment_indent(options) do
233 233 issues = version_issues(project, version)
234 234 render_issues(issues, options)
235 235 end
236 236 end
237 237
238 238 def render_issues(issues, options={})
239 239 self.class.sort_issues!(issues)
240 240 ancestors = []
241 241 issues.each do |issue|
242 242 while ancestors.any? && !issue.is_descendant_of?(ancestors.last)
243 243 ancestors.pop
244 244 decrement_indent(options)
245 245 end
246 246 render_object_row(issue, options)
247 247 unless issue.leaf?
248 248 ancestors << issue
249 249 increment_indent(options)
250 250 end
251 251 end
252 252 decrement_indent(options, ancestors.size)
253 253 end
254 254
255 255 def render_object_row(object, options)
256 256 class_name = object.class.name.downcase
257 257 send("subject_for_#{class_name}", object, options) unless options[:only] == :lines
258 258 send("line_for_#{class_name}", object, options) unless options[:only] == :subjects
259 259 options[:top] += options[:top_increment]
260 260 @number_of_rows += 1
261 261 if @max_rows && @number_of_rows >= @max_rows
262 262 raise MaxLinesLimitReached
263 263 end
264 264 end
265 265
266 266 def render_end(options={})
267 267 case options[:format]
268 268 when :pdf
269 269 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
270 270 end
271 271 end
272 272
273 273 def increment_indent(options, factor=1)
274 274 options[:indent] += options[:indent_increment] * factor
275 275 if block_given?
276 276 yield
277 277 decrement_indent(options, factor)
278 278 end
279 279 end
280 280
281 281 def decrement_indent(options, factor=1)
282 282 increment_indent(options, -factor)
283 283 end
284 284
285 285 def subject_for_project(project, options)
286 286 subject(project.name, options, project)
287 287 end
288 288
289 289 def line_for_project(project, options)
290 290 # Skip projects that don't have a start_date or due date
291 291 if project.is_a?(Project) && project.start_date && project.due_date
292 292 label = project.name
293 293 line(project.start_date, project.due_date, nil, true, label, options, project)
294 294 end
295 295 end
296 296
297 297 def subject_for_version(version, options)
298 298 subject(version.to_s_with_project, options, version)
299 299 end
300 300
301 301 def line_for_version(version, options)
302 302 # Skip versions that don't have a start_date
303 303 if version.is_a?(Version) && version.due_date && version.start_date
304 304 label = "#{h(version)} #{h(version.completed_percent.to_f.round)}%"
305 305 label = h("#{version.project} -") + label unless @project && @project == version.project
306 306 line(version.start_date, version.due_date, version.completed_percent, true, label, options, version)
307 307 end
308 308 end
309 309
310 310 def subject_for_issue(issue, options)
311 311 subject(issue.subject, options, issue)
312 312 end
313 313
314 314 def line_for_issue(issue, options)
315 315 # Skip issues that don't have a due_before (due_date or version's due_date)
316 316 if issue.is_a?(Issue) && issue.due_before
317 317 label = "#{issue.status.name} #{issue.done_ratio}%"
318 318 markers = !issue.leaf?
319 319 line(issue.start_date, issue.due_before, issue.done_ratio, markers, label, options, issue)
320 320 end
321 321 end
322 322
323 323 def subject(label, options, object=nil)
324 324 send "#{options[:format]}_subject", options, label, object
325 325 end
326 326
327 327 def line(start_date, end_date, done_ratio, markers, label, options, object=nil)
328 328 options[:zoom] ||= 1
329 329 options[:g_width] ||= (self.date_to - self.date_from + 1) * options[:zoom]
330 330 coords = coordinates(start_date, end_date, done_ratio, options[:zoom])
331 331 send "#{options[:format]}_task", options, coords, markers, label, object
332 332 end
333 333
334 334 # Generates a gantt image
335 335 # Only defined if RMagick is avalaible
336 336 def to_image(format='PNG')
337 337 date_to = (@date_from >> @months) - 1
338 338 show_weeks = @zoom > 1
339 339 show_days = @zoom > 2
340 340 subject_width = 400
341 341 header_height = 18
342 342 # width of one day in pixels
343 343 zoom = @zoom * 2
344 344 g_width = (@date_to - @date_from + 1) * zoom
345 345 g_height = 20 * number_of_rows + 30
346 346 headers_height = (show_weeks ? 2 * header_height : header_height)
347 347 height = g_height + headers_height
348 348 imgl = Magick::ImageList.new
349 349 imgl.new_image(subject_width + g_width + 1, height)
350 350 gc = Magick::Draw.new
351 351 gc.font = Redmine::Configuration['rmagick_font_path'] || ""
352 352 # Subjects
353 353 gc.stroke('transparent')
354 354 subjects(:image => gc, :top => (headers_height + 20), :indent => 4, :format => :image)
355 355 # Months headers
356 356 month_f = @date_from
357 357 left = subject_width
358 358 @months.times do
359 359 width = ((month_f >> 1) - month_f) * zoom
360 360 gc.fill('white')
361 361 gc.stroke('grey')
362 362 gc.stroke_width(1)
363 363 gc.rectangle(left, 0, left + width, height)
364 364 gc.fill('black')
365 365 gc.stroke('transparent')
366 366 gc.stroke_width(1)
367 367 gc.text(left.round + 8, 14, "#{month_f.year}-#{month_f.month}")
368 368 left = left + width
369 369 month_f = month_f >> 1
370 370 end
371 371 # Weeks headers
372 372 if show_weeks
373 373 left = subject_width
374 374 height = header_height
375 375 if @date_from.cwday == 1
376 376 # date_from is monday
377 377 week_f = date_from
378 378 else
379 379 # find next monday after date_from
380 380 week_f = @date_from + (7 - @date_from.cwday + 1)
381 381 width = (7 - @date_from.cwday + 1) * zoom
382 382 gc.fill('white')
383 383 gc.stroke('grey')
384 384 gc.stroke_width(1)
385 385 gc.rectangle(left, header_height, left + width, 2 * header_height + g_height - 1)
386 386 left = left + width
387 387 end
388 388 while week_f <= date_to
389 389 width = (week_f + 6 <= date_to) ? 7 * zoom : (date_to - week_f + 1) * zoom
390 390 gc.fill('white')
391 391 gc.stroke('grey')
392 392 gc.stroke_width(1)
393 393 gc.rectangle(left.round, header_height, left.round + width, 2 * header_height + g_height - 1)
394 394 gc.fill('black')
395 395 gc.stroke('transparent')
396 396 gc.stroke_width(1)
397 397 gc.text(left.round + 2, header_height + 14, week_f.cweek.to_s)
398 398 left = left + width
399 399 week_f = week_f + 7
400 400 end
401 401 end
402 402 # Days details (week-end in grey)
403 403 if show_days
404 404 left = subject_width
405 405 height = g_height + header_height - 1
406 406 wday = @date_from.cwday
407 407 (date_to - @date_from + 1).to_i.times do
408 408 width = zoom
409 409 gc.fill(non_working_week_days.include?(wday) ? '#eee' : 'white')
410 410 gc.stroke('#ddd')
411 411 gc.stroke_width(1)
412 412 gc.rectangle(left, 2 * header_height, left + width, 2 * header_height + g_height - 1)
413 413 left = left + width
414 414 wday = wday + 1
415 415 wday = 1 if wday > 7
416 416 end
417 417 end
418 418 # border
419 419 gc.fill('transparent')
420 420 gc.stroke('grey')
421 421 gc.stroke_width(1)
422 422 gc.rectangle(0, 0, subject_width + g_width, headers_height)
423 423 gc.stroke('black')
424 424 gc.rectangle(0, 0, subject_width + g_width, g_height + headers_height - 1)
425 425 # content
426 426 top = headers_height + 20
427 427 gc.stroke('transparent')
428 428 lines(:image => gc, :top => top, :zoom => zoom,
429 429 :subject_width => subject_width, :format => :image)
430 430 # today red line
431 431 if User.current.today >= @date_from and User.current.today <= date_to
432 432 gc.stroke('red')
433 433 x = (User.current.today - @date_from + 1) * zoom + subject_width
434 434 gc.line(x, headers_height, x, headers_height + g_height - 1)
435 435 end
436 436 gc.draw(imgl)
437 437 imgl.format = format
438 438 imgl.to_blob
439 439 end if Object.const_defined?(:Magick)
440 440
441 441 def to_pdf
442 442 pdf = ::Redmine::Export::PDF::ITCPDF.new(current_language)
443 443 pdf.SetTitle("#{l(:label_gantt)} #{project}")
444 444 pdf.alias_nb_pages
445 445 pdf.footer_date = format_date(User.current.today)
446 446 pdf.AddPage("L")
447 447 pdf.SetFontStyle('B', 12)
448 448 pdf.SetX(15)
449 449 pdf.RDMCell(PDF::LeftPaneWidth, 20, project.to_s)
450 450 pdf.Ln
451 451 pdf.SetFontStyle('B', 9)
452 452 subject_width = PDF::LeftPaneWidth
453 453 header_height = 5
454 454 headers_height = header_height
455 455 show_weeks = false
456 456 show_days = false
457 457 if self.months < 7
458 458 show_weeks = true
459 459 headers_height = 2 * header_height
460 460 if self.months < 3
461 461 show_days = true
462 462 headers_height = 3 * header_height
463 463 if self.months < 2
464 464 show_day_num = true
465 465 headers_height = 4 * header_height
466 466 end
467 467 end
468 468 end
469 469 g_width = PDF.right_pane_width
470 470 zoom = (g_width) / (self.date_to - self.date_from + 1)
471 471 g_height = 120
472 472 t_height = g_height + headers_height
473 473 y_start = pdf.GetY
474 474 # Months headers
475 475 month_f = self.date_from
476 476 left = subject_width
477 477 height = header_height
478 478 self.months.times do
479 479 width = ((month_f >> 1) - month_f) * zoom
480 480 pdf.SetY(y_start)
481 481 pdf.SetX(left)
482 482 pdf.RDMCell(width, height, "#{month_f.year}-#{month_f.month}", "LTR", 0, "C")
483 483 left = left + width
484 484 month_f = month_f >> 1
485 485 end
486 486 # Weeks headers
487 487 if show_weeks
488 488 left = subject_width
489 489 height = header_height
490 490 if self.date_from.cwday == 1
491 491 # self.date_from is monday
492 492 week_f = self.date_from
493 493 else
494 494 # find next monday after self.date_from
495 495 week_f = self.date_from + (7 - self.date_from.cwday + 1)
496 496 width = (7 - self.date_from.cwday + 1) * zoom-1
497 497 pdf.SetY(y_start + header_height)
498 498 pdf.SetX(left)
499 499 pdf.RDMCell(width + 1, height, "", "LTR")
500 500 left = left + width + 1
501 501 end
502 502 while week_f <= self.date_to
503 503 width = (week_f + 6 <= self.date_to) ? 7 * zoom : (self.date_to - week_f + 1) * zoom
504 504 pdf.SetY(y_start + header_height)
505 505 pdf.SetX(left)
506 506 pdf.RDMCell(width, height, (width >= 5 ? week_f.cweek.to_s : ""), "LTR", 0, "C")
507 507 left = left + width
508 508 week_f = week_f + 7
509 509 end
510 510 end
511 511 # Day numbers headers
512 512 if show_day_num
513 513 left = subject_width
514 514 height = header_height
515 515 day_num = self.date_from
516 516 wday = self.date_from.cwday
517 517 pdf.SetFontStyle('B', 7)
518 518 (self.date_to - self.date_from + 1).to_i.times do
519 519 width = zoom
520 520 pdf.SetY(y_start + header_height * 2)
521 521 pdf.SetX(left)
522 522 pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
523 523 pdf.RDMCell(width, height, day_num.day.to_s, "LTR", 0, "C")
524 524 left = left + width
525 525 day_num = day_num + 1
526 526 wday = wday + 1
527 527 wday = 1 if wday > 7
528 528 end
529 529 end
530 530 # Days headers
531 531 if show_days
532 532 left = subject_width
533 533 height = header_height
534 534 wday = self.date_from.cwday
535 535 pdf.SetFontStyle('B', 7)
536 536 (self.date_to - self.date_from + 1).to_i.times do
537 537 width = zoom
538 538 pdf.SetY(y_start + header_height * (show_day_num ? 3 : 2))
539 539 pdf.SetX(left)
540 540 pdf.SetTextColor(non_working_week_days.include?(wday) ? 150 : 0)
541 541 pdf.RDMCell(width, height, day_name(wday).first, "LTR", 0, "C")
542 542 left = left + width
543 543 wday = wday + 1
544 544 wday = 1 if wday > 7
545 545 end
546 546 end
547 547 pdf.SetY(y_start)
548 548 pdf.SetX(15)
549 549 pdf.SetTextColor(0)
550 550 pdf.RDMCell(subject_width + g_width - 15, headers_height, "", 1)
551 551 # Tasks
552 552 top = headers_height + y_start
553 553 options = {
554 554 :top => top,
555 555 :zoom => zoom,
556 556 :subject_width => subject_width,
557 557 :g_width => g_width,
558 558 :indent => 0,
559 559 :indent_increment => 5,
560 560 :top_increment => 5,
561 561 :format => :pdf,
562 562 :pdf => pdf
563 563 }
564 564 render(options)
565 565 pdf.Output
566 566 end
567 567
568 568 private
569 569
570 570 def coordinates(start_date, end_date, progress, zoom=nil)
571 571 zoom ||= @zoom
572 572 coords = {}
573 573 if start_date && end_date && start_date < self.date_to && end_date > self.date_from
574 574 if start_date > self.date_from
575 575 coords[:start] = start_date - self.date_from
576 576 coords[:bar_start] = start_date - self.date_from
577 577 else
578 578 coords[:bar_start] = 0
579 579 end
580 580 if end_date < self.date_to
581 581 coords[:end] = end_date - self.date_from
582 582 coords[:bar_end] = end_date - self.date_from + 1
583 583 else
584 584 coords[:bar_end] = self.date_to - self.date_from + 1
585 585 end
586 586 if progress
587 587 progress_date = calc_progress_date(start_date, end_date, progress)
588 588 if progress_date > self.date_from && progress_date > start_date
589 589 if progress_date < self.date_to
590 590 coords[:bar_progress_end] = progress_date - self.date_from
591 591 else
592 592 coords[:bar_progress_end] = self.date_to - self.date_from + 1
593 593 end
594 594 end
595 595 if progress_date < User.current.today
596 596 late_date = [User.current.today, end_date].min
597 597 if late_date > self.date_from && late_date > start_date
598 598 if late_date < self.date_to
599 599 coords[:bar_late_end] = late_date - self.date_from + 1
600 600 else
601 601 coords[:bar_late_end] = self.date_to - self.date_from + 1
602 602 end
603 603 end
604 604 end
605 605 end
606 606 end
607 607 # Transforms dates into pixels witdh
608 608 coords.keys.each do |key|
609 609 coords[key] = (coords[key] * zoom).floor
610 610 end
611 611 coords
612 612 end
613 613
614 614 def calc_progress_date(start_date, end_date, progress)
615 615 start_date + (end_date - start_date + 1) * (progress / 100.0)
616 616 end
617 617
618 618 def self.sort_issues!(issues)
619 619 issues.sort! {|a, b| sort_issue_logic(a) <=> sort_issue_logic(b)}
620 620 end
621 621
622 622 def self.sort_issue_logic(issue)
623 623 julian_date = Date.new()
624 624 ancesters_start_date = []
625 625 current_issue = issue
626 626 begin
627 627 ancesters_start_date.unshift([current_issue.start_date || julian_date, current_issue.id])
628 628 current_issue = current_issue.parent
629 629 end while (current_issue)
630 630 ancesters_start_date
631 631 end
632 632
633 633 def self.sort_versions!(versions)
634 634 versions.sort!
635 635 end
636 636
637 637 def pdf_new_page?(options)
638 638 if options[:top] > 180
639 639 options[:pdf].Line(15, options[:top], PDF::TotalWidth, options[:top])
640 640 options[:pdf].AddPage("L")
641 641 options[:top] = 15
642 642 options[:pdf].Line(15, options[:top] - 0.1, PDF::TotalWidth, options[:top] - 0.1)
643 643 end
644 644 end
645 645
646 646 def html_subject_content(object)
647 647 case object
648 648 when Issue
649 649 issue = object
650 650 css_classes = ''
651 651 css_classes << ' issue-overdue' if issue.overdue?
652 652 css_classes << ' issue-behind-schedule' if issue.behind_schedule?
653 653 css_classes << ' icon icon-issue' unless Setting.gravatar_enabled? && issue.assigned_to
654 654 css_classes << ' issue-closed' if issue.closed?
655 655 if issue.start_date && issue.due_before && issue.done_ratio
656 656 progress_date = calc_progress_date(issue.start_date,
657 657 issue.due_before, issue.done_ratio)
658 658 css_classes << ' behind-start-date' if progress_date < self.date_from
659 659 css_classes << ' over-end-date' if progress_date > self.date_to
660 660 end
661 661 s = "".html_safe
662 662 if issue.assigned_to.present?
663 663 assigned_string = l(:field_assigned_to) + ": " + issue.assigned_to.name
664 664 s << view.avatar(issue.assigned_to,
665 665 :class => 'gravatar icon-gravatar',
666 666 :size => 10,
667 667 :title => assigned_string).to_s.html_safe
668 668 end
669 669 s << view.link_to_issue(issue).html_safe
670 670 view.content_tag(:span, s, :class => css_classes).html_safe
671 671 when Version
672 672 version = object
673 673 html_class = ""
674 674 html_class << 'icon icon-package '
675 675 html_class << (version.behind_schedule? ? 'version-behind-schedule' : '') << " "
676 676 html_class << (version.overdue? ? 'version-overdue' : '')
677 677 html_class << ' version-closed' unless version.open?
678 678 if version.start_date && version.due_date && version.completed_percent
679 679 progress_date = calc_progress_date(version.start_date,
680 680 version.due_date, version.completed_percent)
681 681 html_class << ' behind-start-date' if progress_date < self.date_from
682 682 html_class << ' over-end-date' if progress_date > self.date_to
683 683 end
684 684 s = view.link_to_version(version).html_safe
685 685 view.content_tag(:span, s, :class => html_class).html_safe
686 686 when Project
687 687 project = object
688 688 html_class = ""
689 689 html_class << 'icon icon-projects '
690 690 html_class << (project.overdue? ? 'project-overdue' : '')
691 691 s = view.link_to_project(project).html_safe
692 692 view.content_tag(:span, s, :class => html_class).html_safe
693 693 end
694 694 end
695 695
696 696 def html_subject(params, subject, object)
697 697 style = "position: absolute;top:#{params[:top]}px;left:#{params[:indent]}px;"
698 698 style << "width:#{params[:subject_width] - params[:indent]}px;" if params[:subject_width]
699 699 content = html_subject_content(object) || subject
700 700 tag_options = {:style => style}
701 701 case object
702 702 when Issue
703 703 tag_options[:id] = "issue-#{object.id}"
704 704 tag_options[:class] = "issue-subject"
705 705 tag_options[:title] = object.subject
706 706 when Version
707 707 tag_options[:id] = "version-#{object.id}"
708 708 tag_options[:class] = "version-name"
709 709 when Project
710 710 tag_options[:class] = "project-name"
711 711 end
712 712 output = view.content_tag(:div, content, tag_options)
713 713 @subjects << output
714 714 output
715 715 end
716 716
717 717 def pdf_subject(params, subject, options={})
718 718 pdf_new_page?(params)
719 719 params[:pdf].SetY(params[:top])
720 720 params[:pdf].SetX(15)
721 721 char_limit = PDF::MaxCharactorsForSubject - params[:indent]
722 722 params[:pdf].RDMCell(params[:subject_width] - 15, 5,
723 723 (" " * params[:indent]) +
724 724 subject.to_s.sub(/^(.{#{char_limit}}[^\s]*\s).*$/, '\1 (...)'),
725 725 "LR")
726 726 params[:pdf].SetY(params[:top])
727 727 params[:pdf].SetX(params[:subject_width])
728 728 params[:pdf].RDMCell(params[:g_width], 5, "", "LR")
729 729 end
730 730
731 731 def image_subject(params, subject, options={})
732 732 params[:image].fill('black')
733 733 params[:image].stroke('transparent')
734 734 params[:image].stroke_width(1)
735 735 params[:image].text(params[:indent], params[:top] + 2, subject)
736 736 end
737 737
738 738 def issue_relations(issue)
739 739 rels = {}
740 740 if relations[issue.id]
741 741 relations[issue.id].each do |relation|
742 742 (rels[relation.relation_type] ||= []) << relation.issue_to_id
743 743 end
744 744 end
745 745 rels
746 746 end
747 747
748 748 def html_task(params, coords, markers, label, object)
749 749 output = ''
750 750
751 751 css = "task " + case object
752 752 when Project
753 753 "project"
754 754 when Version
755 755 "version"
756 756 when Issue
757 757 object.leaf? ? 'leaf' : 'parent'
758 758 else
759 759 ""
760 760 end
761 761
762 762 # Renders the task bar, with progress and late
763 763 if coords[:bar_start] && coords[:bar_end]
764 764 width = coords[:bar_end] - coords[:bar_start] - 2
765 765 style = ""
766 766 style << "top:#{params[:top]}px;"
767 767 style << "left:#{coords[:bar_start]}px;"
768 768 style << "width:#{width}px;"
769 769 html_id = "task-todo-issue-#{object.id}" if object.is_a?(Issue)
770 770 html_id = "task-todo-version-#{object.id}" if object.is_a?(Version)
771 771 content_opt = {:style => style,
772 772 :class => "#{css} task_todo",
773 773 :id => html_id}
774 774 if object.is_a?(Issue)
775 775 rels = issue_relations(object)
776 776 if rels.present?
777 777 content_opt[:data] = {"rels" => rels.to_json}
778 778 end
779 779 end
780 780 output << view.content_tag(:div, '&nbsp;'.html_safe, content_opt)
781 781 if coords[:bar_late_end]
782 782 width = coords[:bar_late_end] - coords[:bar_start] - 2
783 783 style = ""
784 784 style << "top:#{params[:top]}px;"
785 785 style << "left:#{coords[:bar_start]}px;"
786 786 style << "width:#{width}px;"
787 787 output << view.content_tag(:div, '&nbsp;'.html_safe,
788 788 :style => style,
789 789 :class => "#{css} task_late")
790 790 end
791 791 if coords[:bar_progress_end]
792 792 width = coords[:bar_progress_end] - coords[:bar_start] - 2
793 793 style = ""
794 794 style << "top:#{params[:top]}px;"
795 795 style << "left:#{coords[:bar_start]}px;"
796 796 style << "width:#{width}px;"
797 797 html_id = "task-done-issue-#{object.id}" if object.is_a?(Issue)
798 798 html_id = "task-done-version-#{object.id}" if object.is_a?(Version)
799 799 output << view.content_tag(:div, '&nbsp;'.html_safe,
800 800 :style => style,
801 801 :class => "#{css} task_done",
802 802 :id => html_id)
803 803 end
804 804 end
805 805 # Renders the markers
806 806 if markers
807 807 if coords[:start]
808 808 style = ""
809 809 style << "top:#{params[:top]}px;"
810 810 style << "left:#{coords[:start]}px;"
811 811 style << "width:15px;"
812 812 output << view.content_tag(:div, '&nbsp;'.html_safe,
813 813 :style => style,
814 814 :class => "#{css} marker starting")
815 815 end
816 816 if coords[:end]
817 817 style = ""
818 818 style << "top:#{params[:top]}px;"
819 819 style << "left:#{coords[:end] + params[:zoom]}px;"
820 820 style << "width:15px;"
821 821 output << view.content_tag(:div, '&nbsp;'.html_safe,
822 822 :style => style,
823 823 :class => "#{css} marker ending")
824 824 end
825 825 end
826 826 # Renders the label on the right
827 827 if label
828 828 style = ""
829 829 style << "top:#{params[:top]}px;"
830 830 style << "left:#{(coords[:bar_end] || 0) + 8}px;"
831 831 style << "width:15px;"
832 832 output << view.content_tag(:div, label,
833 833 :style => style,
834 834 :class => "#{css} label")
835 835 end
836 836 # Renders the tooltip
837 837 if object.is_a?(Issue) && coords[:bar_start] && coords[:bar_end]
838 838 s = view.content_tag(:span,
839 839 view.render_issue_tooltip(object).html_safe,
840 840 :class => "tip")
841 841 style = ""
842 842 style << "position: absolute;"
843 843 style << "top:#{params[:top]}px;"
844 844 style << "left:#{coords[:bar_start]}px;"
845 845 style << "width:#{coords[:bar_end] - coords[:bar_start]}px;"
846 846 style << "height:12px;"
847 847 output << view.content_tag(:div, s.html_safe,
848 848 :style => style,
849 849 :class => "tooltip")
850 850 end
851 851 @lines << output
852 852 output
853 853 end
854 854
855 855 def pdf_task(params, coords, markers, label, object)
856 856 cell_height_ratio = params[:pdf].get_cell_height_ratio()
857 857 params[:pdf].set_cell_height_ratio(0.1)
858 858
859 859 height = 2
860 860 height /= 2 if markers
861 861 # Renders the task bar, with progress and late
862 862 if coords[:bar_start] && coords[:bar_end]
863 863 params[:pdf].SetY(params[:top] + 1.5)
864 864 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
865 865 params[:pdf].SetFillColor(200, 200, 200)
866 866 params[:pdf].RDMCell(coords[:bar_end] - coords[:bar_start], height, "", 0, 0, "", 1)
867 867 if coords[:bar_late_end]
868 868 params[:pdf].SetY(params[:top] + 1.5)
869 869 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
870 870 params[:pdf].SetFillColor(255, 100, 100)
871 871 params[:pdf].RDMCell(coords[:bar_late_end] - coords[:bar_start], height, "", 0, 0, "", 1)
872 872 end
873 873 if coords[:bar_progress_end]
874 874 params[:pdf].SetY(params[:top] + 1.5)
875 875 params[:pdf].SetX(params[:subject_width] + coords[:bar_start])
876 876 params[:pdf].SetFillColor(90, 200, 90)
877 877 params[:pdf].RDMCell(coords[:bar_progress_end] - coords[:bar_start], height, "", 0, 0, "", 1)
878 878 end
879 879 end
880 880 # Renders the markers
881 881 if markers
882 882 if coords[:start]
883 883 params[:pdf].SetY(params[:top] + 1)
884 884 params[:pdf].SetX(params[:subject_width] + coords[:start] - 1)
885 885 params[:pdf].SetFillColor(50, 50, 200)
886 886 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
887 887 end
888 888 if coords[:end]
889 889 params[:pdf].SetY(params[:top] + 1)
890 890 params[:pdf].SetX(params[:subject_width] + coords[:end] - 1)
891 891 params[:pdf].SetFillColor(50, 50, 200)
892 892 params[:pdf].RDMCell(2, 2, "", 0, 0, "", 1)
893 893 end
894 894 end
895 895 # Renders the label on the right
896 896 if label
897 897 params[:pdf].SetX(params[:subject_width] + (coords[:bar_end] || 0) + 5)
898 898 params[:pdf].RDMCell(30, 2, label)
899 899 end
900 900
901 901 params[:pdf].set_cell_height_ratio(cell_height_ratio)
902 902 end
903 903
904 904 def image_task(params, coords, markers, label, object)
905 905 height = 6
906 906 height /= 2 if markers
907 907 # Renders the task bar, with progress and late
908 908 if coords[:bar_start] && coords[:bar_end]
909 909 params[:image].fill('#aaa')
910 910 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
911 911 params[:top],
912 912 params[:subject_width] + coords[:bar_end],
913 913 params[:top] - height)
914 914 if coords[:bar_late_end]
915 915 params[:image].fill('#f66')
916 916 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
917 917 params[:top],
918 918 params[:subject_width] + coords[:bar_late_end],
919 919 params[:top] - height)
920 920 end
921 921 if coords[:bar_progress_end]
922 922 params[:image].fill('#00c600')
923 923 params[:image].rectangle(params[:subject_width] + coords[:bar_start],
924 924 params[:top],
925 925 params[:subject_width] + coords[:bar_progress_end],
926 926 params[:top] - height)
927 927 end
928 928 end
929 929 # Renders the markers
930 930 if markers
931 931 if coords[:start]
932 932 x = params[:subject_width] + coords[:start]
933 933 y = params[:top] - height / 2
934 934 params[:image].fill('blue')
935 935 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
936 936 end
937 937 if coords[:end]
938 938 x = params[:subject_width] + coords[:end] + params[:zoom]
939 939 y = params[:top] - height / 2
940 940 params[:image].fill('blue')
941 941 params[:image].polygon(x - 4, y, x, y - 4, x + 4, y, x, y + 4)
942 942 end
943 943 end
944 944 # Renders the label on the right
945 945 if label
946 946 params[:image].fill('black')
947 947 params[:image].text(params[:subject_width] + (coords[:bar_end] || 0) + 5,
948 948 params[:top] + 1,
949 949 label)
950 950 end
951 951 end
952 952 end
953 953 end
954 954 end
@@ -1,200 +1,200
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module Redmine
19 19 module NestedSet
20 20 module IssueNestedSet
21 21 def self.included(base)
22 22 base.class_eval do
23 23 belongs_to :parent, :class_name => self.name
24 24
25 25 before_create :add_to_nested_set, :if => lambda {|issue| issue.parent.present?}
26 26 after_create :add_as_root, :if => lambda {|issue| issue.parent.blank?}
27 27 before_update :handle_parent_change, :if => lambda {|issue| issue.parent_id_changed?}
28 28 before_destroy :destroy_children
29 29 end
30 30 base.extend ClassMethods
31 31 base.send :include, Redmine::NestedSet::Traversing
32 32 end
33 33
34 34 private
35 35
36 36 def target_lft
37 37 scope_for_max_rgt = self.class.where(:root_id => root_id).where(:parent_id => parent_id)
38 38 if id
39 39 scope_for_max_rgt = scope_for_max_rgt.where("id < ?", id)
40 40 end
41 41 max_rgt = scope_for_max_rgt.maximum(:rgt)
42 42 if max_rgt
43 43 max_rgt + 1
44 44 elsif parent
45 45 parent.lft + 1
46 46 else
47 47 1
48 48 end
49 49 end
50 50
51 51 def add_to_nested_set(lock=true)
52 52 lock_nested_set if lock
53 53 parent.send :reload_nested_set_values
54 54 self.root_id = parent.root_id
55 55 self.lft = target_lft
56 56 self.rgt = lft + 1
57 57 self.class.where(:root_id => root_id).where("lft >= ? OR rgt >= ?", lft, lft).update_all([
58 58 "lft = CASE WHEN lft >= :lft THEN lft + 2 ELSE lft END, " +
59 59 "rgt = CASE WHEN rgt >= :lft THEN rgt + 2 ELSE rgt END",
60 60 {:lft => lft}
61 61 ])
62 62 end
63 63
64 64 def add_as_root
65 65 self.root_id = id
66 66 self.lft = 1
67 67 self.rgt = 2
68 68 self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
69 69 end
70 70
71 71 def handle_parent_change
72 72 lock_nested_set
73 73 reload_nested_set_values
74 74 if parent_id_was
75 75 remove_from_nested_set
76 76 end
77 77 if parent
78 78 move_to_nested_set
79 79 end
80 80 reload_nested_set_values
81 81 end
82 82
83 83 def move_to_nested_set
84 84 if parent
85 85 previous_root_id = root_id
86 86 self.root_id = parent.root_id
87 87
88 88 lft_after_move = target_lft
89 89 self.class.where(:root_id => parent.root_id).update_all([
90 90 "lft = CASE WHEN lft >= :lft THEN lft + :shift ELSE lft END, " +
91 91 "rgt = CASE WHEN rgt >= :lft THEN rgt + :shift ELSE rgt END",
92 92 {:lft => lft_after_move, :shift => (rgt - lft + 1)}
93 93 ])
94 94
95 95 self.class.where(:root_id => previous_root_id).update_all([
96 96 "root_id = :root_id, lft = lft + :shift, rgt = rgt + :shift",
97 97 {:root_id => parent.root_id, :shift => lft_after_move - lft}
98 98 ])
99 99
100 100 self.lft, self.rgt = lft_after_move, (rgt - lft + lft_after_move)
101 101 parent.send :reload_nested_set_values
102 102 end
103 103 end
104 104
105 105 def remove_from_nested_set
106 106 self.class.where(:root_id => root_id).where("lft >= ? AND rgt <= ?", lft, rgt).
107 107 update_all(["root_id = :id, lft = lft - :shift, rgt = rgt - :shift", {:id => id, :shift => lft - 1}])
108 108
109 109 self.class.where(:root_id => root_id).update_all([
110 110 "lft = CASE WHEN lft >= :lft THEN lft - :shift ELSE lft END, " +
111 111 "rgt = CASE WHEN rgt >= :lft THEN rgt - :shift ELSE rgt END",
112 112 {:lft => lft, :shift => rgt - lft + 1}
113 113 ])
114 114 self.root_id = id
115 115 self.lft, self.rgt = 1, (rgt - lft + 1)
116 116 end
117 117
118 118 def destroy_children
119 119 unless @without_nested_set_update
120 120 lock_nested_set
121 121 reload_nested_set_values
122 122 end
123 123 children.each {|c| c.send :destroy_without_nested_set_update}
124 124 reload
125 125 unless @without_nested_set_update
126 126 self.class.where(:root_id => root_id).where("lft > ? OR rgt > ?", lft, lft).update_all([
127 127 "lft = CASE WHEN lft > :lft THEN lft - :shift ELSE lft END, " +
128 128 "rgt = CASE WHEN rgt > :lft THEN rgt - :shift ELSE rgt END",
129 129 {:lft => lft, :shift => rgt - lft + 1}
130 130 ])
131 131 end
132 132 end
133 133
134 134 def destroy_without_nested_set_update
135 135 @without_nested_set_update = true
136 136 destroy
137 137 end
138 138
139 139 def reload_nested_set_values
140 140 self.root_id, self.lft, self.rgt = self.class.where(:id => id).pluck(:root_id, :lft, :rgt).first
141 141 end
142 142
143 143 def save_nested_set_values
144 144 self.class.where(:id => id).update_all(:root_id => root_id, :lft => lft, :rgt => rgt)
145 145 end
146 146
147 147 def move_possible?(issue)
148 148 new_record? || !is_or_is_ancestor_of?(issue)
149 149 end
150 150
151 151 def lock_nested_set
152 152 if self.class.connection.adapter_name =~ /sqlserver/i
153 153 lock = "WITH (ROWLOCK HOLDLOCK UPDLOCK)"
154 154 # Custom lock for SQLServer
155 155 # This can be problematic if root_id or parent root_id changes
156 156 # before locking
157 157 sets_to_lock = [root_id, parent.try(:root_id)].compact.uniq
158 158 self.class.reorder(:id).where(:root_id => sets_to_lock).lock(lock).ids
159 159 else
160 160 sets_to_lock = [id, parent_id].compact
161 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 162 end
163 163 end
164 164
165 165 def nested_set_scope
166 166 self.class.order(:lft).where(:root_id => root_id)
167 167 end
168 168
169 169 def same_nested_set_scope?(issue)
170 170 root_id == issue.root_id
171 171 end
172 172
173 173 module ClassMethods
174 174 def rebuild_tree!
175 175 transaction do
176 176 reorder(:id).lock.ids
177 177 update_all(:root_id => nil, :lft => nil, :rgt => nil)
178 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 180 roots_with_children.each do |root_id|
181 181 rebuild_nodes(root_id)
182 182 end
183 183 end
184 184 end
185 185
186 186 private
187 187
188 188 def rebuild_nodes(parent_id = nil)
189 189 nodes = where(:parent_id => parent_id, :rgt => nil, :lft => nil).order(:id).to_a
190 190
191 191 nodes.each do |node|
192 192 node.send :add_to_nested_set, false
193 193 node.send :save_nested_set_values
194 194 rebuild_nodes node.id
195 195 end
196 196 end
197 197 end
198 198 end
199 199 end
200 200 end
@@ -1,244 +1,244
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class IssuesTest < Redmine::IntegrationTest
21 21 fixtures :projects,
22 22 :users, :email_addresses,
23 23 :roles,
24 24 :members,
25 25 :member_roles,
26 26 :trackers,
27 27 :projects_trackers,
28 28 :enabled_modules,
29 29 :issue_statuses,
30 30 :issues,
31 31 :enumerations,
32 32 :custom_fields,
33 33 :custom_values,
34 34 :custom_fields_trackers,
35 35 :attachments
36 36
37 37 # create an issue
38 38 def test_add_issue
39 39 log_user('jsmith', 'jsmith')
40 40
41 41 get '/projects/ecookbook/issues/new'
42 42 assert_response :success
43 43 assert_template 'issues/new'
44 44
45 45 issue = new_record(Issue) do
46 46 post '/projects/ecookbook/issues',
47 47 :issue => { :tracker_id => "1",
48 48 :start_date => "2006-12-26",
49 49 :priority_id => "4",
50 50 :subject => "new test issue",
51 51 :category_id => "",
52 52 :description => "new issue",
53 53 :done_ratio => "0",
54 54 :due_date => "",
55 55 :assigned_to_id => "" },
56 56 :custom_fields => {'2' => 'Value for field 2'}
57 57 end
58 58 # check redirection
59 59 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
60 60 follow_redirect!
61 61 assert_equal issue, assigns(:issue)
62 62
63 63 # check issue attributes
64 64 assert_equal 'jsmith', issue.author.login
65 65 assert_equal 1, issue.project.id
66 66 assert_equal 1, issue.status.id
67 67 end
68 68
69 69 def test_create_issue_by_anonymous_without_permission_should_fail
70 70 Role.anonymous.remove_permission! :add_issues
71 71
72 72 assert_no_difference 'Issue.count' do
73 73 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
74 74 end
75 75 assert_response 302
76 76 end
77 77
78 78 def test_create_issue_by_anonymous_with_custom_permission_should_succeed
79 79 Role.anonymous.remove_permission! :add_issues
80 80 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [3])
81 81
82 82 issue = new_record(Issue) do
83 83 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
84 84 assert_response 302
85 85 end
86 86 assert_equal User.anonymous, issue.author
87 87 end
88 88
89 89 # add then remove 2 attachments to an issue
90 90 def test_issue_attachments
91 91 log_user('jsmith', 'jsmith')
92 92 set_tmp_attachments_directory
93 93
94 94 attachment = new_record(Attachment) do
95 95 put '/issues/1',
96 96 :notes => 'Some notes',
97 97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'This is an attachment'}}
98 98 assert_redirected_to "/issues/1"
99 99 end
100 100
101 101 assert_equal Issue.find(1), attachment.container
102 102 assert_equal 'testfile.txt', attachment.filename
103 103 assert_equal 'This is an attachment', attachment.description
104 104 # verify the size of the attachment stored in db
105 105 #assert_equal file_data_1.length, attachment.filesize
106 106 # verify that the attachment was written to disk
107 107 assert File.exist?(attachment.diskfile)
108 108
109 109 # remove the attachments
110 110 Issue.find(1).attachments.each(&:destroy)
111 111 assert_equal 0, Issue.find(1).attachments.length
112 112 end
113 113
114 114 def test_other_formats_links_on_index
115 115 get '/projects/ecookbook/issues'
116 116
117 117 %w(Atom PDF CSV).each do |format|
118 118 assert_select 'a[rel=nofollow][href=?]', "/projects/ecookbook/issues.#{format.downcase}", :text => format
119 119 end
120 120 end
121 121
122 122 def test_other_formats_links_on_index_without_project_id_in_url
123 123 get '/issues', :project_id => 'ecookbook'
124 124
125 125 %w(Atom PDF CSV).each do |format|
126 126 assert_select 'a[rel=nofollow][href=?]', "/issues.#{format.downcase}?project_id=ecookbook", :text => format
127 127 end
128 128 end
129 129
130 130 def test_pagination_links_on_index
131 131 with_settings :per_page_options => '2' do
132 132 get '/projects/ecookbook/issues'
133 133
134 134 assert_select 'a[href=?]', '/projects/ecookbook/issues?page=2', :text => '2'
135 135 end
136 136 end
137 137
138 138 def test_pagination_links_should_preserve_query_parameters
139 139 with_settings :per_page_options => '2' do
140 140 get '/projects/ecookbook/issues?foo=bar'
141 141
142 142 assert_select 'a[href=?]', '/projects/ecookbook/issues?foo=bar&page=2', :text => '2'
143 143 end
144 144 end
145 145
146 146 def test_pagination_links_should_not_use_params_as_url_options
147 147 with_settings :per_page_options => '2' do
148 148 get '/projects/ecookbook/issues?host=foo'
149 149
150 150 assert_select 'a[href=?]', '/projects/ecookbook/issues?host=foo&page=2', :text => '2'
151 151 end
152 152 end
153 153
154 154 def test_sort_links_on_index
155 155 get '/projects/ecookbook/issues'
156 156
157 157 assert_select 'a[href=?]', '/projects/ecookbook/issues?sort=subject%2Cid%3Adesc', :text => 'Subject'
158 158 end
159 159
160 160 def test_sort_links_should_preserve_query_parameters
161 161 get '/projects/ecookbook/issues?foo=bar'
162 162
163 163 assert_select 'a[href=?]', '/projects/ecookbook/issues?foo=bar&sort=subject%2Cid%3Adesc', :text => 'Subject'
164 164 end
165 165
166 166 def test_sort_links_should_not_use_params_as_url_options
167 167 get '/projects/ecookbook/issues?host=foo'
168 168
169 169 assert_select 'a[href=?]', '/projects/ecookbook/issues?host=foo&sort=subject%2Cid%3Adesc', :text => 'Subject'
170 170 end
171 171
172 172 def test_issue_with_user_custom_field
173 173 @field = IssueCustomField.create!(:name => 'Tester', :field_format => 'user', :is_for_all => true, :trackers => Tracker.all)
174 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 176 tester = users.first
177 177
178 178 # Issue form
179 179 get '/projects/ecookbook/issues/new'
180 180 assert_response :success
181 181 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
182 182 assert_select 'option', users.size + 1 # +1 for blank value
183 183 assert_select 'option[value=?]', tester.id.to_s, :text => tester.name
184 184 end
185 185
186 186 # Create issue
187 187 issue = new_record(Issue) do
188 188 post '/projects/ecookbook/issues',
189 189 :issue => {
190 190 :tracker_id => '1',
191 191 :priority_id => '4',
192 192 :subject => 'Issue with user custom field',
193 193 :custom_field_values => {@field.id.to_s => users.first.id.to_s}
194 194 }
195 195 assert_response 302
196 196 end
197 197
198 198 # Issue view
199 199 follow_redirect!
200 200 assert_select ".cf_#{@field.id}" do
201 201 assert_select '.label', :text => 'Tester:'
202 202 assert_select '.value', :text => tester.name
203 203 end
204 204 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
205 205 assert_select 'option', users.size + 1 # +1 for blank value
206 206 assert_select 'option[value=?][selected=selected]', tester.id.to_s, :text => tester.name
207 207 end
208 208
209 209 new_tester = users[1]
210 210 with_settings :default_language => 'en' do
211 211 # Update issue
212 212 assert_difference 'Journal.count' do
213 213 put "/issues/#{issue.id}",
214 214 :notes => 'Updating custom field',
215 215 :issue => {
216 216 :custom_field_values => {@field.id.to_s => new_tester.id.to_s}
217 217 }
218 218 assert_redirected_to "/issues/#{issue.id}"
219 219 end
220 220 # Issue view
221 221 follow_redirect!
222 222 assert_select 'ul.details li', :text => "Tester changed from #{tester} to #{new_tester}"
223 223 end
224 224 end
225 225
226 226 def test_update_using_invalid_http_verbs
227 227 subject = 'Updated by an invalid http verb'
228 228
229 229 get '/issues/update/1', {:issue => {:subject => subject}}, credentials('jsmith')
230 230 assert_response 404
231 231 assert_not_equal subject, Issue.find(1).subject
232 232
233 233 post '/issues/1', {:issue => {:subject => subject}}, credentials('jsmith')
234 234 assert_response 404
235 235 assert_not_equal subject, Issue.find(1).subject
236 236 end
237 237
238 238 def test_get_watch_should_be_invalid
239 239 assert_no_difference 'Watcher.count' do
240 240 get '/watchers/watch?object_type=issue&object_id=1', {}, credentials('jsmith')
241 241 assert_response 404
242 242 end
243 243 end
244 244 end
@@ -1,264 +1,264
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class ProjectMembersInheritanceTest < ActiveSupport::TestCase
21 21 fixtures :roles, :users,
22 22 :projects, :trackers, :issue_statuses
23 23
24 24 def setup
25 25 @parent = Project.generate!
26 26 @member = Member.create!(:principal => User.find(2), :project => @parent, :role_ids => [1, 2])
27 27 assert_equal 2, @member.reload.roles.size
28 28 end
29 29
30 30 def test_project_created_with_inherit_members_disabled_should_not_inherit_members
31 31 assert_no_difference 'Member.count' do
32 32 project = Project.generate_with_parent!(@parent, :inherit_members => false)
33 33
34 34 assert_equal 0, project.memberships.count
35 35 end
36 36 end
37 37
38 38 def test_project_created_with_inherit_members_should_inherit_members
39 39 assert_difference 'Member.count', 1 do
40 40 project = Project.generate_with_parent!(@parent, :inherit_members => true)
41 41 project.reload
42 42
43 43 assert_equal 1, project.memberships.count
44 44 member = project.memberships.first
45 45 assert_equal @member.principal, member.principal
46 46 assert_equal @member.roles.sort, member.roles.sort
47 47 end
48 48 end
49 49
50 50 def test_turning_on_inherit_members_should_inherit_members
51 51 Project.generate_with_parent!(@parent, :inherit_members => false)
52 52
53 53 assert_difference 'Member.count', 1 do
54 54 project = Project.order('id desc').first
55 55 project.inherit_members = true
56 56 project.save!
57 57 project.reload
58 58
59 59 assert_equal 1, project.memberships.count
60 60 member = project.memberships.first
61 61 assert_equal @member.principal, member.principal
62 62 assert_equal @member.roles.sort, member.roles.sort
63 63 end
64 64 end
65 65
66 66 def test_turning_off_inherit_members_should_remove_inherited_members
67 67 Project.generate_with_parent!(@parent, :inherit_members => true)
68 68
69 69 assert_difference 'Member.count', -1 do
70 70 project = Project.order('id desc').first
71 71 project.inherit_members = false
72 72 project.save!
73 73 project.reload
74 74
75 75 assert_equal 0, project.memberships.count
76 76 end
77 77 end
78 78
79 79 def test_moving_a_root_project_under_a_parent_should_inherit_members
80 80 Project.generate!(:inherit_members => true)
81 81 project = Project.order('id desc').first
82 82
83 83 assert_difference 'Member.count', 1 do
84 84 project.set_parent!(@parent)
85 85 project.reload
86 86
87 87 assert_equal 1, project.memberships.count
88 88 member = project.memberships.first
89 89 assert_equal @member.principal, member.principal
90 90 assert_equal @member.roles.sort, member.roles.sort
91 91 end
92 92 end
93 93
94 94 def test_moving_a_subproject_as_root_should_loose_inherited_members
95 95 Project.generate_with_parent!(@parent, :inherit_members => true)
96 96 project = Project.order('id desc').first
97 97
98 98 assert_difference 'Member.count', -1 do
99 99 project.set_parent!(nil)
100 100 project.reload
101 101
102 102 assert_equal 0, project.memberships.count
103 103 end
104 104 end
105 105
106 106 def test_moving_a_subproject_to_another_parent_should_change_inherited_members
107 107 other_parent = Project.generate!
108 108 other_member = Member.create!(:principal => User.find(4), :project => other_parent, :role_ids => [3])
109 109 other_member.reload
110 110
111 111 Project.generate_with_parent!(@parent, :inherit_members => true)
112 112 project = Project.order('id desc').first
113 113 project.set_parent!(other_parent.reload)
114 114 project.reload
115 115
116 116 assert_equal 1, project.memberships.count
117 117 member = project.memberships.first
118 118 assert_equal other_member.principal, member.principal
119 119 assert_equal other_member.roles.sort, member.roles.sort
120 120 end
121 121
122 122 def test_inheritance_should_propagate_to_subprojects
123 123 project = Project.generate_with_parent!(@parent, :inherit_members => false)
124 124 subproject = Project.generate_with_parent!(project, :inherit_members => true)
125 125 project.reload
126 126
127 127 assert_difference 'Member.count', 2 do
128 128 project.inherit_members = true
129 129 project.save
130 130 project.reload
131 131 subproject.reload
132 132
133 133 assert_equal 1, project.memberships.count
134 134 assert_equal 1, subproject.memberships.count
135 135 member = subproject.memberships.first
136 136 assert_equal @member.principal, member.principal
137 137 assert_equal @member.roles.sort, member.roles.sort
138 138 end
139 139 end
140 140
141 141 def test_inheritance_removal_should_propagate_to_subprojects
142 142 project = Project.generate_with_parent!(@parent, :inherit_members => true)
143 143 subproject = Project.generate_with_parent!(project, :inherit_members => true)
144 144 project.reload
145 145
146 146 assert_difference 'Member.count', -2 do
147 147 project.inherit_members = false
148 148 project.save
149 149 project.reload
150 150 subproject.reload
151 151
152 152 assert_equal 0, project.memberships.count
153 153 assert_equal 0, subproject.memberships.count
154 154 end
155 155 end
156 156
157 157 def test_adding_a_member_should_propagate
158 158 project = Project.generate_with_parent!(@parent, :inherit_members => true)
159 159
160 160 assert_difference 'Member.count', 2 do
161 161 member = Member.create!(:principal => User.find(4), :project => @parent, :role_ids => [1, 3])
162 162 member.reload
163 163
164 164 inherited_member = project.memberships.order('id desc').first
165 165 assert_equal member.principal, inherited_member.principal
166 166 assert_equal member.roles.sort, inherited_member.roles.sort
167 167 end
168 168 end
169 169
170 170 def test_adding_a_member_should_not_propagate_if_child_does_not_inherit
171 171 project = Project.generate_with_parent!(@parent, :inherit_members => false)
172 172
173 173 assert_difference 'Member.count', 1 do
174 174 member = Member.create!(:principal => User.find(4), :project => @parent, :role_ids => [1, 3])
175 175
176 176 assert_nil project.reload.memberships.detect {|m| m.principal == member.principal}
177 177 end
178 178 end
179 179
180 180 def test_removing_a_member_should_propagate
181 181 project = Project.generate_with_parent!(@parent, :inherit_members => true)
182 182
183 183 assert_difference 'Member.count', -2 do
184 184 @member.reload.destroy
185 185 project.reload
186 186
187 187 assert_equal 0, project.memberships.count
188 188 end
189 189 end
190 190
191 191 def test_adding_a_group_member_should_propagate_with_its_users
192 192 project = Project.generate_with_parent!(@parent, :inherit_members => true)
193 193 group = Group.generate!
194 194 user = User.find(4)
195 195 group.users << user
196 196
197 197 assert_difference 'Member.count', 4 do
198 198 assert_difference 'MemberRole.count', 8 do
199 199 member = Member.create!(:principal => group, :project => @parent, :role_ids => [1, 3])
200 200 project.reload
201 201 member.reload
202 202
203 203 inherited_group_member = project.memberships.detect {|m| m.principal == group}
204 204 assert_not_nil inherited_group_member
205 205 assert_equal member.roles.sort, inherited_group_member.roles.sort
206 206
207 207 inherited_user_member = project.memberships.detect {|m| m.principal == user}
208 208 assert_not_nil inherited_user_member
209 209 assert_equal member.roles.sort, inherited_user_member.roles.sort
210 210 end
211 211 end
212 212 end
213 213
214 214 def test_removing_a_group_member_should_propagate
215 215 project = Project.generate_with_parent!(@parent, :inherit_members => true)
216 216 group = Group.generate!
217 217 user = User.find(4)
218 218 group.users << user
219 219 member = Member.create!(:principal => group, :project => @parent, :role_ids => [1, 3])
220 220
221 221 assert_difference 'Member.count', -4 do
222 222 assert_difference 'MemberRole.count', -8 do
223 223 member.destroy
224 224 project.reload
225 225
226 226 inherited_group_member = project.memberships.detect {|m| m.principal == group}
227 227 assert_nil inherited_group_member
228 228
229 229 inherited_user_member = project.memberships.detect {|m| m.principal == user}
230 230 assert_nil inherited_user_member
231 231 end
232 232 end
233 233 end
234 234
235 235 def test_adding_user_who_use_is_already_a_member_to_parent_project_should_merge_roles
236 236 project = Project.generate_with_parent!(@parent, :inherit_members => true)
237 237 user = User.find(4)
238 238 Member.create!(:principal => user, :project => project, :role_ids => [1, 2])
239 239
240 240 assert_difference 'Member.count', 1 do
241 241 Member.create!(:principal => User.find(4), :project => @parent.reload, :role_ids => [1, 3])
242 242
243 243 member = project.reload.memberships.detect {|m| m.principal == user}
244 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 246 end
247 247 end
248 248
249 249 def test_turning_on_inheritance_with_user_who_is_already_a_member_should_merge_roles
250 250 project = Project.generate_with_parent!(@parent)
251 251 user = @member.user
252 252 Member.create!(:principal => user, :project => project, :role_ids => [1, 3])
253 253 project.reload
254 254
255 255 assert_no_difference 'Member.count' do
256 256 project.inherit_members = true
257 257 project.save!
258 258
259 259 member = project.reload.memberships.detect {|m| m.principal == user}
260 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 262 end
263 263 end
264 264 end
@@ -1,1003 +1,1003
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class ProjectTest < ActiveSupport::TestCase
21 21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 22 :journals, :journal_details,
23 23 :enumerations, :users, :issue_categories,
24 24 :projects_trackers,
25 25 :custom_fields,
26 26 :custom_fields_projects,
27 27 :custom_fields_trackers,
28 28 :custom_values,
29 29 :roles,
30 30 :member_roles,
31 31 :members,
32 32 :enabled_modules,
33 33 :versions,
34 34 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
35 35 :groups_users,
36 36 :boards, :messages,
37 37 :repositories,
38 38 :news, :comments,
39 39 :documents,
40 40 :workflows
41 41
42 42 def setup
43 43 @ecookbook = Project.find(1)
44 44 @ecookbook_sub1 = Project.find(3)
45 45 set_tmp_attachments_directory
46 46 User.current = nil
47 47 end
48 48
49 49 def test_truth
50 50 assert_kind_of Project, @ecookbook
51 51 assert_equal "eCookbook", @ecookbook.name
52 52 end
53 53
54 54 def test_default_attributes
55 55 with_settings :default_projects_public => '1' do
56 56 assert_equal true, Project.new.is_public
57 57 assert_equal false, Project.new(:is_public => false).is_public
58 58 end
59 59
60 60 with_settings :default_projects_public => '0' do
61 61 assert_equal false, Project.new.is_public
62 62 assert_equal true, Project.new(:is_public => true).is_public
63 63 end
64 64
65 65 with_settings :sequential_project_identifiers => '1' do
66 66 assert !Project.new.identifier.blank?
67 67 assert Project.new(:identifier => '').identifier.blank?
68 68 end
69 69
70 70 with_settings :sequential_project_identifiers => '0' do
71 71 assert Project.new.identifier.blank?
72 72 assert !Project.new(:identifier => 'test').blank?
73 73 end
74 74
75 75 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
76 76 assert_equal ['issue_tracking', 'repository'], Project.new.enabled_module_names
77 77 end
78 78 end
79 79
80 80 def test_default_trackers_should_match_default_tracker_ids_setting
81 81 with_settings :default_projects_tracker_ids => ['1', '3'] do
82 82 assert_equal Tracker.find(1, 3).sort, Project.new.trackers.sort
83 83 end
84 84 end
85 85
86 86 def test_default_trackers_should_be_all_trackers_with_blank_setting
87 87 with_settings :default_projects_tracker_ids => nil do
88 88 assert_equal Tracker.all.sort, Project.new.trackers.sort
89 89 end
90 90 end
91 91
92 92 def test_default_trackers_should_be_empty_with_empty_setting
93 93 with_settings :default_projects_tracker_ids => [] do
94 94 assert_equal [], Project.new.trackers
95 95 end
96 96 end
97 97
98 98 def test_default_trackers_should_not_replace_initialized_trackers
99 99 with_settings :default_projects_tracker_ids => ['1', '3'] do
100 100 assert_equal Tracker.find(1, 2).sort, Project.new(:tracker_ids => [1, 2]).trackers.sort
101 101 end
102 102 end
103 103
104 104 def test_update
105 105 assert_equal "eCookbook", @ecookbook.name
106 106 @ecookbook.name = "eCook"
107 107 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
108 108 @ecookbook.reload
109 109 assert_equal "eCook", @ecookbook.name
110 110 end
111 111
112 112 def test_validate_identifier
113 113 to_test = {"abc" => true,
114 114 "ab12" => true,
115 115 "ab-12" => true,
116 116 "ab_12" => true,
117 117 "12" => false,
118 118 "new" => false}
119 119
120 120 to_test.each do |identifier, valid|
121 121 p = Project.new
122 122 p.identifier = identifier
123 123 p.valid?
124 124 if valid
125 125 assert p.errors['identifier'].blank?, "identifier #{identifier} was not valid"
126 126 else
127 127 assert p.errors['identifier'].present?, "identifier #{identifier} was valid"
128 128 end
129 129 end
130 130 end
131 131
132 132 def test_identifier_should_not_be_frozen_for_a_new_project
133 133 assert_equal false, Project.new.identifier_frozen?
134 134 end
135 135
136 136 def test_identifier_should_not_be_frozen_for_a_saved_project_with_blank_identifier
137 137 Project.where(:id => 1).update_all(["identifier = ''"])
138 138 assert_equal false, Project.find(1).identifier_frozen?
139 139 end
140 140
141 141 def test_identifier_should_be_frozen_for_a_saved_project_with_valid_identifier
142 142 assert_equal true, Project.find(1).identifier_frozen?
143 143 end
144 144
145 145 def test_to_param_should_be_nil_for_new_records
146 146 project = Project.new
147 147 project.identifier = "foo"
148 148 assert_nil project.to_param
149 149 end
150 150
151 151 def test_members_should_be_active_users
152 152 Project.all.each do |project|
153 153 assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) }
154 154 end
155 155 end
156 156
157 157 def test_users_should_be_active_users
158 158 Project.all.each do |project|
159 159 assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) }
160 160 end
161 161 end
162 162
163 163 def test_open_scope_on_issues_association
164 164 assert_kind_of Issue, Project.find(1).issues.open.first
165 165 end
166 166
167 167 def test_archive
168 168 user = @ecookbook.members.first.user
169 169 @ecookbook.archive
170 170 @ecookbook.reload
171 171
172 172 assert !@ecookbook.active?
173 173 assert @ecookbook.archived?
174 174 assert !user.projects.include?(@ecookbook)
175 175 # Subproject are also archived
176 176 assert !@ecookbook.children.empty?
177 177 assert @ecookbook.descendants.active.empty?
178 178 end
179 179
180 180 def test_archive_should_fail_if_versions_are_used_by_non_descendant_projects
181 181 # Assign an issue of a project to a version of a child project
182 182 Issue.find(4).update_attribute :fixed_version_id, 4
183 183
184 184 assert_no_difference "Project.where(:status => Project::STATUS_ARCHIVED).count" do
185 185 assert_equal false, @ecookbook.archive
186 186 end
187 187 @ecookbook.reload
188 188 assert @ecookbook.active?
189 189 end
190 190
191 191 def test_unarchive
192 192 user = @ecookbook.members.first.user
193 193 @ecookbook.archive
194 194 # A subproject of an archived project can not be unarchived
195 195 assert !@ecookbook_sub1.unarchive
196 196
197 197 # Unarchive project
198 198 assert @ecookbook.unarchive
199 199 @ecookbook.reload
200 200 assert @ecookbook.active?
201 201 assert !@ecookbook.archived?
202 202 assert user.projects.include?(@ecookbook)
203 203 # Subproject can now be unarchived
204 204 @ecookbook_sub1.reload
205 205 assert @ecookbook_sub1.unarchive
206 206 end
207 207
208 208 def test_destroy
209 209 # 2 active members
210 210 assert_equal 2, @ecookbook.members.size
211 211 # and 1 is locked
212 212 assert_equal 3, Member.where(:project_id => @ecookbook.id).count
213 213 # some boards
214 214 assert @ecookbook.boards.any?
215 215
216 216 @ecookbook.destroy
217 217 # make sure that the project non longer exists
218 218 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
219 219 # make sure related data was removed
220 220 assert_nil Member.where(:project_id => @ecookbook.id).first
221 221 assert_nil Board.where(:project_id => @ecookbook.id).first
222 222 assert_nil Issue.where(:project_id => @ecookbook.id).first
223 223 end
224 224
225 225 def test_destroy_should_destroy_subtasks
226 226 issues = (0..2).to_a.map {Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test')}
227 227 issues[0].update_attribute :parent_issue_id, issues[1].id
228 228 issues[2].update_attribute :parent_issue_id, issues[1].id
229 229 assert_equal 2, issues[1].children.count
230 230
231 231 assert_nothing_raised do
232 232 Project.find(1).destroy
233 233 end
234 234 assert_equal 0, Issue.where(:id => issues.map(&:id)).count
235 235 end
236 236
237 237 def test_destroying_root_projects_should_clear_data
238 238 Project.roots.each do |root|
239 239 root.destroy
240 240 end
241 241
242 242 assert_equal 0, Project.count, "Projects were not deleted: #{Project.all.inspect}"
243 243 assert_equal 0, Member.count, "Members were not deleted: #{Member.all.inspect}"
244 244 assert_equal 0, MemberRole.count
245 245 assert_equal 0, Issue.count
246 246 assert_equal 0, Journal.count
247 247 assert_equal 0, JournalDetail.count
248 248 assert_equal 0, Attachment.count, "Attachments were not deleted: #{Attachment.all.inspect}"
249 249 assert_equal 0, EnabledModule.count
250 250 assert_equal 0, IssueCategory.count
251 251 assert_equal 0, IssueRelation.count
252 252 assert_equal 0, Board.count
253 253 assert_equal 0, Message.count
254 254 assert_equal 0, News.count
255 255 assert_equal 0, Query.where("project_id IS NOT NULL").count
256 256 assert_equal 0, Repository.count
257 257 assert_equal 0, Changeset.count
258 258 assert_equal 0, Change.count
259 259 assert_equal 0, Comment.count
260 260 assert_equal 0, TimeEntry.count
261 261 assert_equal 0, Version.count
262 262 assert_equal 0, Watcher.count
263 263 assert_equal 0, Wiki.count
264 264 assert_equal 0, WikiPage.count
265 265 assert_equal 0, WikiContent.count
266 266 assert_equal 0, WikiContent::Version.count
267 267 assert_equal 0, Project.connection.select_all("SELECT * FROM projects_trackers").count
268 268 assert_equal 0, Project.connection.select_all("SELECT * FROM custom_fields_projects").count
269 269 assert_equal 0, CustomValue.where(:customized_type => ['Project', 'Issue', 'TimeEntry', 'Version']).count
270 270 end
271 271
272 272 def test_destroy_should_delete_time_entries_custom_values
273 273 project = Project.generate!
274 274 time_entry = TimeEntry.generate!(:project => project, :custom_field_values => {10 => '1'})
275 275
276 276 assert_difference 'CustomValue.where(:customized_type => "TimeEntry").count', -1 do
277 277 assert project.destroy
278 278 end
279 279 end
280 280
281 281 def test_move_an_orphan_project_to_a_root_project
282 282 sub = Project.find(2)
283 283 sub.set_parent! @ecookbook
284 284 assert_equal @ecookbook.id, sub.parent.id
285 285 @ecookbook.reload
286 286 assert_equal 4, @ecookbook.children.size
287 287 end
288 288
289 289 def test_move_an_orphan_project_to_a_subproject
290 290 sub = Project.find(2)
291 291 assert sub.set_parent!(@ecookbook_sub1)
292 292 end
293 293
294 294 def test_move_a_root_project_to_a_project
295 295 sub = @ecookbook
296 296 assert sub.set_parent!(Project.find(2))
297 297 end
298 298
299 299 def test_should_not_move_a_project_to_its_children
300 300 sub = @ecookbook
301 301 assert !(sub.set_parent!(Project.find(3)))
302 302 end
303 303
304 304 def test_set_parent_should_add_roots_in_alphabetical_order
305 305 ProjectCustomField.delete_all
306 306 Project.delete_all
307 307 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
308 308 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
309 309 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
310 310 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
311 311
312 312 assert_equal 4, Project.count
313 313 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
314 314 end
315 315
316 316 def test_set_parent_should_add_children_in_alphabetical_order
317 317 ProjectCustomField.delete_all
318 318 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
319 319 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
320 320 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
321 321 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
322 322 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
323 323
324 324 parent.reload
325 325 assert_equal 4, parent.children.size
326 326 assert_equal parent.children.sort_by(&:name), parent.children.to_a
327 327 end
328 328
329 329 def test_set_parent_should_update_issue_fixed_version_associations_when_a_fixed_version_is_moved_out_of_the_hierarchy
330 330 # Parent issue with a hierarchy project's fixed version
331 331 parent_issue = Issue.find(1)
332 332 parent_issue.update_attribute(:fixed_version_id, 4)
333 333 parent_issue.reload
334 334 assert_equal 4, parent_issue.fixed_version_id
335 335
336 336 # Should keep fixed versions for the issues
337 337 issue_with_local_fixed_version = Issue.find(5)
338 338 issue_with_local_fixed_version.update_attribute(:fixed_version_id, 4)
339 339 issue_with_local_fixed_version.reload
340 340 assert_equal 4, issue_with_local_fixed_version.fixed_version_id
341 341
342 342 # Local issue with hierarchy fixed_version
343 343 issue_with_hierarchy_fixed_version = Issue.find(13)
344 344 issue_with_hierarchy_fixed_version.update_attribute(:fixed_version_id, 6)
345 345 issue_with_hierarchy_fixed_version.reload
346 346 assert_equal 6, issue_with_hierarchy_fixed_version.fixed_version_id
347 347
348 348 # Move project out of the issue's hierarchy
349 349 moved_project = Project.find(3)
350 350 moved_project.set_parent!(Project.find(2))
351 351 parent_issue.reload
352 352 issue_with_local_fixed_version.reload
353 353 issue_with_hierarchy_fixed_version.reload
354 354
355 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 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 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 358 end
359 359
360 360 def test_parent
361 361 p = Project.find(6).parent
362 362 assert p.is_a?(Project)
363 363 assert_equal 5, p.id
364 364 end
365 365
366 366 def test_ancestors
367 367 a = Project.find(6).ancestors
368 368 assert a.first.is_a?(Project)
369 369 assert_equal [1, 5], a.collect(&:id)
370 370 end
371 371
372 372 def test_root
373 373 r = Project.find(6).root
374 374 assert r.is_a?(Project)
375 375 assert_equal 1, r.id
376 376 end
377 377
378 378 def test_children
379 379 c = Project.find(1).children
380 380 assert c.first.is_a?(Project)
381 381 assert_equal [5, 3, 4], c.collect(&:id)
382 382 end
383 383
384 384 def test_descendants
385 385 d = Project.find(1).descendants
386 386 assert d.first.is_a?(Project)
387 387 assert_equal [5, 6, 3, 4], d.collect(&:id)
388 388 end
389 389
390 390 def test_allowed_parents_should_be_empty_for_non_member_user
391 391 Role.non_member.add_permission!(:add_project)
392 392 user = User.find(9)
393 393 assert user.memberships.empty?
394 394 User.current = user
395 395 assert Project.new.allowed_parents.compact.empty?
396 396 end
397 397
398 398 def test_allowed_parents_with_add_subprojects_permission
399 399 Role.find(1).remove_permission!(:add_project)
400 400 Role.find(1).add_permission!(:add_subprojects)
401 401 User.current = User.find(2)
402 402 # new project
403 403 assert !Project.new.allowed_parents.include?(nil)
404 404 assert Project.new.allowed_parents.include?(Project.find(1))
405 405 # existing root project
406 406 assert Project.find(1).allowed_parents.include?(nil)
407 407 # existing child
408 408 assert Project.find(3).allowed_parents.include?(Project.find(1))
409 409 assert !Project.find(3).allowed_parents.include?(nil)
410 410 end
411 411
412 412 def test_allowed_parents_with_add_project_permission
413 413 Role.find(1).add_permission!(:add_project)
414 414 Role.find(1).remove_permission!(:add_subprojects)
415 415 User.current = User.find(2)
416 416 # new project
417 417 assert Project.new.allowed_parents.include?(nil)
418 418 assert !Project.new.allowed_parents.include?(Project.find(1))
419 419 # existing root project
420 420 assert Project.find(1).allowed_parents.include?(nil)
421 421 # existing child
422 422 assert Project.find(3).allowed_parents.include?(Project.find(1))
423 423 assert Project.find(3).allowed_parents.include?(nil)
424 424 end
425 425
426 426 def test_allowed_parents_with_add_project_and_subprojects_permission
427 427 Role.find(1).add_permission!(:add_project)
428 428 Role.find(1).add_permission!(:add_subprojects)
429 429 User.current = User.find(2)
430 430 # new project
431 431 assert Project.new.allowed_parents.include?(nil)
432 432 assert Project.new.allowed_parents.include?(Project.find(1))
433 433 # existing root project
434 434 assert Project.find(1).allowed_parents.include?(nil)
435 435 # existing child
436 436 assert Project.find(3).allowed_parents.include?(Project.find(1))
437 437 assert Project.find(3).allowed_parents.include?(nil)
438 438 end
439 439
440 440 def test_users_by_role
441 441 users_by_role = Project.find(1).users_by_role
442 442 assert_kind_of Hash, users_by_role
443 443 role = Role.find(1)
444 444 assert_kind_of Array, users_by_role[role]
445 445 assert users_by_role[role].include?(User.find(2))
446 446 end
447 447
448 448 def test_rolled_up_trackers
449 449 parent = Project.find(1)
450 450 parent.trackers = Tracker.find([1,2])
451 451 child = parent.children.find(3)
452 452
453 453 assert_equal [1, 2], parent.tracker_ids
454 454 assert_equal [2, 3], child.trackers.collect(&:id)
455 455
456 456 assert_kind_of Tracker, parent.rolled_up_trackers.first
457 457 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
458 458
459 459 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
460 460 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
461 461 end
462 462
463 463 def test_rolled_up_trackers_should_ignore_archived_subprojects
464 464 parent = Project.find(1)
465 465 parent.trackers = Tracker.find([1,2])
466 466 child = parent.children.find(3)
467 467 child.trackers = Tracker.find([1,3])
468 468 parent.children.each(&:archive)
469 469
470 470 assert_equal [1,2], parent.rolled_up_trackers.collect(&:id)
471 471 end
472 472
473 473 test "#rolled_up_trackers should ignore projects with issue_tracking module disabled" do
474 474 parent = Project.generate!
475 475 parent.trackers = Tracker.find([1, 2])
476 476 child = Project.generate_with_parent!(parent)
477 477 child.trackers = Tracker.find([2, 3])
478 478
479 479 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id).sort
480 480
481 481 assert child.disable_module!(:issue_tracking)
482 482 parent.reload
483 483 assert_equal [1, 2], parent.rolled_up_trackers.collect(&:id).sort
484 484 end
485 485
486 486 test "#rolled_up_versions should include the versions for the current project" do
487 487 project = Project.generate!
488 488 parent_version_1 = Version.generate!(:project => project)
489 489 parent_version_2 = Version.generate!(:project => project)
490 490 assert_equal [parent_version_1, parent_version_2].sort,
491 491 project.rolled_up_versions.sort
492 492 end
493 493
494 494 test "#rolled_up_versions should include versions for a subproject" do
495 495 project = Project.generate!
496 496 parent_version_1 = Version.generate!(:project => project)
497 497 parent_version_2 = Version.generate!(:project => project)
498 498 subproject = Project.generate_with_parent!(project)
499 499 subproject_version = Version.generate!(:project => subproject)
500 500
501 501 assert_equal [parent_version_1, parent_version_2, subproject_version].sort,
502 502 project.rolled_up_versions.sort
503 503 end
504 504
505 505 test "#rolled_up_versions should include versions for a sub-subproject" do
506 506 project = Project.generate!
507 507 parent_version_1 = Version.generate!(:project => project)
508 508 parent_version_2 = Version.generate!(:project => project)
509 509 subproject = Project.generate_with_parent!(project)
510 510 sub_subproject = Project.generate_with_parent!(subproject)
511 511 sub_subproject_version = Version.generate!(:project => sub_subproject)
512 512 project.reload
513 513
514 514 assert_equal [parent_version_1, parent_version_2, sub_subproject_version].sort,
515 515 project.rolled_up_versions.sort
516 516 end
517 517
518 518 test "#rolled_up_versions should only check active projects" do
519 519 project = Project.generate!
520 520 parent_version_1 = Version.generate!(:project => project)
521 521 parent_version_2 = Version.generate!(:project => project)
522 522 subproject = Project.generate_with_parent!(project)
523 523 subproject_version = Version.generate!(:project => subproject)
524 524 assert subproject.archive
525 525 project.reload
526 526
527 527 assert !subproject.active?
528 528 assert_equal [parent_version_1, parent_version_2].sort,
529 529 project.rolled_up_versions.sort
530 530 end
531 531
532 532 def test_shared_versions_none_sharing
533 533 p = Project.find(5)
534 534 v = Version.create!(:name => 'none_sharing', :project => p, :sharing => 'none')
535 535 assert p.shared_versions.include?(v)
536 536 assert !p.children.first.shared_versions.include?(v)
537 537 assert !p.root.shared_versions.include?(v)
538 538 assert !p.siblings.first.shared_versions.include?(v)
539 539 assert !p.root.siblings.first.shared_versions.include?(v)
540 540 end
541 541
542 542 def test_shared_versions_descendants_sharing
543 543 p = Project.find(5)
544 544 v = Version.create!(:name => 'descendants_sharing', :project => p, :sharing => 'descendants')
545 545 assert p.shared_versions.include?(v)
546 546 assert p.children.first.shared_versions.include?(v)
547 547 assert !p.root.shared_versions.include?(v)
548 548 assert !p.siblings.first.shared_versions.include?(v)
549 549 assert !p.root.siblings.first.shared_versions.include?(v)
550 550 end
551 551
552 552 def test_shared_versions_hierarchy_sharing
553 553 p = Project.find(5)
554 554 v = Version.create!(:name => 'hierarchy_sharing', :project => p, :sharing => 'hierarchy')
555 555 assert p.shared_versions.include?(v)
556 556 assert p.children.first.shared_versions.include?(v)
557 557 assert p.root.shared_versions.include?(v)
558 558 assert !p.siblings.first.shared_versions.include?(v)
559 559 assert !p.root.siblings.first.shared_versions.include?(v)
560 560 end
561 561
562 562 def test_shared_versions_tree_sharing
563 563 p = Project.find(5)
564 564 v = Version.create!(:name => 'tree_sharing', :project => p, :sharing => 'tree')
565 565 assert p.shared_versions.include?(v)
566 566 assert p.children.first.shared_versions.include?(v)
567 567 assert p.root.shared_versions.include?(v)
568 568 assert p.siblings.first.shared_versions.include?(v)
569 569 assert !p.root.siblings.first.shared_versions.include?(v)
570 570 end
571 571
572 572 def test_shared_versions_system_sharing
573 573 p = Project.find(5)
574 574 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
575 575 assert p.shared_versions.include?(v)
576 576 assert p.children.first.shared_versions.include?(v)
577 577 assert p.root.shared_versions.include?(v)
578 578 assert p.siblings.first.shared_versions.include?(v)
579 579 assert p.root.siblings.first.shared_versions.include?(v)
580 580 end
581 581
582 582 def test_shared_versions
583 583 parent = Project.find(1)
584 584 child = parent.children.find(3)
585 585 private_child = parent.children.find(5)
586 586
587 587 assert_equal [1,2,3], parent.version_ids.sort
588 588 assert_equal [4], child.version_ids
589 589 assert_equal [6], private_child.version_ids
590 590 assert_equal [7], Version.where(:sharing => 'system').collect(&:id)
591 591
592 592 assert_equal 6, parent.shared_versions.size
593 593 parent.shared_versions.each do |version|
594 594 assert_kind_of Version, version
595 595 end
596 596
597 597 assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort
598 598 end
599 599
600 600 def test_shared_versions_should_ignore_archived_subprojects
601 601 parent = Project.find(1)
602 602 child = parent.children.find(3)
603 603 child.archive
604 604 parent.reload
605 605
606 606 assert_equal [1,2,3], parent.version_ids.sort
607 607 assert_equal [4], child.version_ids
608 608 assert !parent.shared_versions.collect(&:id).include?(4)
609 609 end
610 610
611 611 def test_shared_versions_visible_to_user
612 612 user = User.find(3)
613 613 parent = Project.find(1)
614 614 child = parent.children.find(5)
615 615
616 616 assert_equal [1,2,3], parent.version_ids.sort
617 617 assert_equal [6], child.version_ids
618 618
619 619 versions = parent.shared_versions.visible(user)
620 620
621 621 assert_equal 4, versions.size
622 622 versions.each do |version|
623 623 assert_kind_of Version, version
624 624 end
625 625
626 626 assert !versions.collect(&:id).include?(6)
627 627 end
628 628
629 629 def test_shared_versions_for_new_project_should_include_system_shared_versions
630 630 p = Project.find(5)
631 631 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
632 632
633 633 assert_include v, Project.new.shared_versions
634 634 end
635 635
636 636 def test_next_identifier
637 637 ProjectCustomField.delete_all
638 638 Project.create!(:name => 'last', :identifier => 'p2008040')
639 639 assert_equal 'p2008041', Project.next_identifier
640 640 end
641 641
642 642 def test_next_identifier_first_project
643 643 Project.delete_all
644 644 assert_nil Project.next_identifier
645 645 end
646 646
647 647 def test_enabled_module_names
648 648 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
649 649 project = Project.new
650 650
651 651 project.enabled_module_names = %w(issue_tracking news)
652 652 assert_equal %w(issue_tracking news), project.enabled_module_names.sort
653 653 end
654 654 end
655 655
656 656 def test_enabled_modules_names_with_nil_should_clear_modules
657 657 p = Project.find(1)
658 658 p.enabled_module_names = nil
659 659 assert_equal [], p.enabled_modules
660 660 end
661 661
662 662 test "enabled_modules should define module by names and preserve ids" do
663 663 @project = Project.find(1)
664 664 # Remove one module
665 665 modules = @project.enabled_modules.slice(0..-2)
666 666 assert modules.any?
667 667 assert_difference 'EnabledModule.count', -1 do
668 668 @project.enabled_module_names = modules.collect(&:name)
669 669 end
670 670 @project.reload
671 671 # Ids should be preserved
672 672 assert_equal @project.enabled_module_ids.sort, modules.collect(&:id).sort
673 673 end
674 674
675 675 test "enabled_modules should enable a module" do
676 676 @project = Project.find(1)
677 677 @project.enabled_module_names = []
678 678 @project.reload
679 679 assert_equal [], @project.enabled_module_names
680 680 #with string
681 681 @project.enable_module!("issue_tracking")
682 682 assert_equal ["issue_tracking"], @project.enabled_module_names
683 683 #with symbol
684 684 @project.enable_module!(:gantt)
685 685 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
686 686 #don't add a module twice
687 687 @project.enable_module!("issue_tracking")
688 688 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
689 689 end
690 690
691 691 test "enabled_modules should disable a module" do
692 692 @project = Project.find(1)
693 693 #with string
694 694 assert @project.enabled_module_names.include?("issue_tracking")
695 695 @project.disable_module!("issue_tracking")
696 696 assert ! @project.reload.enabled_module_names.include?("issue_tracking")
697 697 #with symbol
698 698 assert @project.enabled_module_names.include?("gantt")
699 699 @project.disable_module!(:gantt)
700 700 assert ! @project.reload.enabled_module_names.include?("gantt")
701 701 #with EnabledModule object
702 702 first_module = @project.enabled_modules.first
703 703 @project.disable_module!(first_module)
704 704 assert ! @project.reload.enabled_module_names.include?(first_module.name)
705 705 end
706 706
707 707 def test_enabled_module_names_should_not_recreate_enabled_modules
708 708 project = Project.find(1)
709 709 # Remove one module
710 710 modules = project.enabled_modules.slice(0..-2)
711 711 assert modules.any?
712 712 assert_difference 'EnabledModule.count', -1 do
713 713 project.enabled_module_names = modules.collect(&:name)
714 714 end
715 715 project.reload
716 716 # Ids should be preserved
717 717 assert_equal project.enabled_module_ids.sort, modules.collect(&:id).sort
718 718 end
719 719
720 720 def test_copy_from_existing_project
721 721 source_project = Project.find(1)
722 722 copied_project = Project.copy_from(1)
723 723
724 724 assert copied_project
725 725 # Cleared attributes
726 726 assert copied_project.id.blank?
727 727 assert copied_project.name.blank?
728 728 assert copied_project.identifier.blank?
729 729
730 730 # Duplicated attributes
731 731 assert_equal source_project.description, copied_project.description
732 732 assert_equal source_project.trackers, copied_project.trackers
733 733
734 734 # Default attributes
735 735 assert_equal 1, copied_project.status
736 736 end
737 737
738 738 def test_copy_from_should_copy_enabled_modules
739 739 source = Project.generate!
740 740 source.enabled_module_names = %w(issue_tracking wiki)
741 741
742 742 copy = Project.copy_from(source)
743 743 copy.name = 'Copy'
744 744 copy.identifier = 'copy'
745 745 assert_difference 'EnabledModule.count', 2 do
746 746 copy.save!
747 747 end
748 748 assert_equal 2, copy.reload.enabled_modules.count
749 749 assert_equal 2, source.reload.enabled_modules.count
750 750 end
751 751
752 752 def test_activities_should_use_the_system_activities
753 753 project = Project.find(1)
754 754 assert_equal project.activities.to_a, TimeEntryActivity.where(:active => true).to_a
755 755 assert_kind_of ActiveRecord::Relation, project.activities
756 756 end
757 757
758 758
759 759 def test_activities_should_use_the_project_specific_activities
760 760 project = Project.find(1)
761 761 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project})
762 762 assert overridden_activity.save!
763 763
764 764 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
765 765 assert_kind_of ActiveRecord::Relation, project.activities
766 766 end
767 767
768 768 def test_activities_should_not_include_the_inactive_project_specific_activities
769 769 project = Project.find(1)
770 770 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.first, :active => false})
771 771 assert overridden_activity.save!
772 772
773 773 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity found"
774 774 end
775 775
776 776 def test_activities_should_not_include_project_specific_activities_from_other_projects
777 777 project = Project.find(1)
778 778 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(2)})
779 779 assert overridden_activity.save!
780 780
781 781 assert !project.activities.include?(overridden_activity), "Project specific Activity found on a different project"
782 782 end
783 783
784 784 def test_activities_should_handle_nils
785 785 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.first})
786 786 TimeEntryActivity.delete_all
787 787
788 788 # No activities
789 789 project = Project.find(1)
790 790 assert project.activities.empty?
791 791
792 792 # No system, one overridden
793 793 assert overridden_activity.save!
794 794 project.reload
795 795 assert_equal [overridden_activity], project.activities
796 796 end
797 797
798 798 def test_activities_should_override_system_activities_with_project_activities
799 799 project = Project.find(1)
800 800 parent_activity = TimeEntryActivity.first
801 801 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => parent_activity})
802 802 assert overridden_activity.save!
803 803
804 804 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
805 805 assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
806 806 end
807 807
808 808 def test_activities_should_include_inactive_activities_if_specified
809 809 project = Project.find(1)
810 810 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.first, :active => false})
811 811 assert overridden_activity.save!
812 812
813 813 assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
814 814 end
815 815
816 816 test 'activities should not include active System activities if the project has an override that is inactive' do
817 817 project = Project.find(1)
818 818 system_activity = TimeEntryActivity.find_by_name('Design')
819 819 assert system_activity.active?
820 820 overridden_activity = TimeEntryActivity.create!(:name => "Project", :project => project, :parent => system_activity, :active => false)
821 821 assert overridden_activity.save!
822 822
823 823 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity not found"
824 824 assert !project.activities.include?(system_activity), "System activity found when the project has an inactive override"
825 825 end
826 826
827 827 def test_close_completed_versions
828 828 Version.update_all("status = 'open'")
829 829 project = Project.find(1)
830 830 assert_not_nil project.versions.detect {|v| v.completed? && v.status == 'open'}
831 831 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
832 832 project.close_completed_versions
833 833 project.reload
834 834 assert_nil project.versions.detect {|v| v.completed? && v.status != 'closed'}
835 835 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
836 836 end
837 837
838 838 test "#start_date should be nil if there are no issues on the project" do
839 839 project = Project.generate!
840 840 assert_nil project.start_date
841 841 end
842 842
843 843 test "#start_date should be nil when issues have no start date" do
844 844 project = Project.generate!
845 845 project.trackers << Tracker.generate!
846 846 early = 7.days.ago.to_date
847 847 Issue.generate!(:project => project, :start_date => nil)
848 848
849 849 assert_nil project.start_date
850 850 end
851 851
852 852 test "#start_date should be the earliest start date of it's issues" do
853 853 project = Project.generate!
854 854 project.trackers << Tracker.generate!
855 855 early = 7.days.ago.to_date
856 856 Issue.generate!(:project => project, :start_date => Date.today)
857 857 Issue.generate!(:project => project, :start_date => early)
858 858
859 859 assert_equal early, project.start_date
860 860 end
861 861
862 862 test "#due_date should be nil if there are no issues on the project" do
863 863 project = Project.generate!
864 864 assert_nil project.due_date
865 865 end
866 866
867 867 test "#due_date should be nil if there are no issues with due dates" do
868 868 project = Project.generate!
869 869 project.trackers << Tracker.generate!
870 870 Issue.generate!(:project => project, :due_date => nil)
871 871
872 872 assert_nil project.due_date
873 873 end
874 874
875 875 test "#due_date should be the latest due date of it's issues" do
876 876 project = Project.generate!
877 877 project.trackers << Tracker.generate!
878 878 future = 7.days.from_now.to_date
879 879 Issue.generate!(:project => project, :due_date => future)
880 880 Issue.generate!(:project => project, :due_date => Date.today)
881 881
882 882 assert_equal future, project.due_date
883 883 end
884 884
885 885 test "#due_date should be the latest due date of it's versions" do
886 886 project = Project.generate!
887 887 future = 7.days.from_now.to_date
888 888 project.versions << Version.generate!(:effective_date => future)
889 889 project.versions << Version.generate!(:effective_date => Date.today)
890 890
891 891 assert_equal future, project.due_date
892 892 end
893 893
894 894 test "#due_date should pick the latest date from it's issues and versions" do
895 895 project = Project.generate!
896 896 project.trackers << Tracker.generate!
897 897 future = 7.days.from_now.to_date
898 898 far_future = 14.days.from_now.to_date
899 899 Issue.generate!(:project => project, :due_date => far_future)
900 900 project.versions << Version.generate!(:effective_date => future)
901 901
902 902 assert_equal far_future, project.due_date
903 903 end
904 904
905 905 test "#completed_percent with no versions should be 100" do
906 906 project = Project.generate!
907 907 assert_equal 100, project.completed_percent
908 908 end
909 909
910 910 test "#completed_percent with versions should return 0 if the versions have no issues" do
911 911 project = Project.generate!
912 912 Version.generate!(:project => project)
913 913 Version.generate!(:project => project)
914 914
915 915 assert_equal 0, project.completed_percent
916 916 end
917 917
918 918 test "#completed_percent with versions should return 100 if the version has only closed issues" do
919 919 project = Project.generate!
920 920 project.trackers << Tracker.generate!
921 921 v1 = Version.generate!(:project => project)
922 922 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v1)
923 923 v2 = Version.generate!(:project => project)
924 924 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v2)
925 925
926 926 assert_equal 100, project.completed_percent
927 927 end
928 928
929 929 test "#completed_percent with versions should return the averaged completed percent of the versions (not weighted)" do
930 930 project = Project.generate!
931 931 project.trackers << Tracker.generate!
932 932 v1 = Version.generate!(:project => project)
933 933 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v1)
934 934 v2 = Version.generate!(:project => project)
935 935 Issue.generate!(:project => project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v2)
936 936
937 937 assert_equal 50, project.completed_percent
938 938 end
939 939
940 940 test "#notified_users" do
941 941 project = Project.generate!
942 942 role = Role.generate!
943 943
944 944 user_with_membership_notification = User.generate!(:mail_notification => 'selected')
945 945 Member.create!(:project => project, :roles => [role], :principal => user_with_membership_notification, :mail_notification => true)
946 946
947 947 all_events_user = User.generate!(:mail_notification => 'all')
948 948 Member.create!(:project => project, :roles => [role], :principal => all_events_user)
949 949
950 950 no_events_user = User.generate!(:mail_notification => 'none')
951 951 Member.create!(:project => project, :roles => [role], :principal => no_events_user)
952 952
953 953 only_my_events_user = User.generate!(:mail_notification => 'only_my_events')
954 954 Member.create!(:project => project, :roles => [role], :principal => only_my_events_user)
955 955
956 956 only_assigned_user = User.generate!(:mail_notification => 'only_assigned')
957 957 Member.create!(:project => project, :roles => [role], :principal => only_assigned_user)
958 958
959 959 only_owned_user = User.generate!(:mail_notification => 'only_owner')
960 960 Member.create!(:project => project, :roles => [role], :principal => only_owned_user)
961 961
962 962 assert project.notified_users.include?(user_with_membership_notification), "should include members with a mail notification"
963 963 assert project.notified_users.include?(all_events_user), "should include users with the 'all' notification option"
964 964 assert !project.notified_users.include?(no_events_user), "should not include users with the 'none' notification option"
965 965 assert !project.notified_users.include?(only_my_events_user), "should not include users with the 'only_my_events' notification option"
966 966 assert !project.notified_users.include?(only_assigned_user), "should not include users with the 'only_assigned' notification option"
967 967 assert !project.notified_users.include?(only_owned_user), "should not include users with the 'only_owner' notification option"
968 968 end
969 969
970 970 def test_override_roles_without_builtin_group_memberships
971 971 project = Project.generate!
972 972 assert_equal [Role.anonymous], project.override_roles(Role.anonymous)
973 973 assert_equal [Role.non_member], project.override_roles(Role.non_member)
974 974 end
975 975
976 976 def test_css_classes
977 977 p = Project.new
978 978 assert_kind_of String, p.css_classes
979 979 assert_not_include 'archived', p.css_classes.split
980 980 assert_not_include 'closed', p.css_classes.split
981 981 end
982 982
983 983 def test_css_classes_for_archived_project
984 984 p = Project.new
985 985 p.status = Project::STATUS_ARCHIVED
986 986 assert_include 'archived', p.css_classes.split
987 987 end
988 988
989 989 def test_css_classes_for_closed_project
990 990 p = Project.new
991 991 p.status = Project::STATUS_CLOSED
992 992 assert_include 'closed', p.css_classes.split
993 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 996 project = Project.find(1)
997 997 member = Member.create!(:project => project, :principal => Group.anonymous, :roles => [Role.generate!])
998 998 project.members << member
999 999 assert_nothing_raised do
1000 Project.uniq.visible.to_a
1000 Project.distinct.visible.to_a
1001 1001 end
1002 1002 end
1003 1003 end
General Comments 0
You need to be logged in to leave comments. Login now