##// END OF EJS Templates
Makes visible scopes accept projects option and deprecate Project.visible_by....
Jean-Philippe Lang -
r5204:405fc07e9073
parent child
Show More
@@ -1,272 +1,272
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2010 Jean-Philippe Lang
2 # Copyright (C) 2006-2010 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'iconv'
18 require 'iconv'
19
19
20 class Changeset < ActiveRecord::Base
20 class Changeset < ActiveRecord::Base
21 belongs_to :repository
21 belongs_to :repository
22 belongs_to :user
22 belongs_to :user
23 has_many :changes, :dependent => :delete_all
23 has_many :changes, :dependent => :delete_all
24 has_and_belongs_to_many :issues
24 has_and_belongs_to_many :issues
25
25
26 acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.format_identifier}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))},
26 acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.format_identifier}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))},
27 :description => :long_comments,
27 :description => :long_comments,
28 :datetime => :committed_on,
28 :datetime => :committed_on,
29 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.identifier}}
29 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.identifier}}
30
30
31 acts_as_searchable :columns => 'comments',
31 acts_as_searchable :columns => 'comments',
32 :include => {:repository => :project},
32 :include => {:repository => :project},
33 :project_key => "#{Repository.table_name}.project_id",
33 :project_key => "#{Repository.table_name}.project_id",
34 :date_column => 'committed_on'
34 :date_column => 'committed_on'
35
35
36 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
36 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
37 :author_key => :user_id,
37 :author_key => :user_id,
38 :find_options => {:include => [:user, {:repository => :project}]}
38 :find_options => {:include => [:user, {:repository => :project}]}
39
39
40 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
40 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
41 validates_uniqueness_of :revision, :scope => :repository_id
41 validates_uniqueness_of :revision, :scope => :repository_id
42 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
42 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
43
43
44 named_scope :visible, lambda {|*args| { :include => {:repository => :project},
44 named_scope :visible, lambda {|*args| { :include => {:repository => :project},
45 :conditions => Project.allowed_to_condition(args.first || User.current, :view_changesets) } }
45 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
46
46
47 def revision=(r)
47 def revision=(r)
48 write_attribute :revision, (r.nil? ? nil : r.to_s)
48 write_attribute :revision, (r.nil? ? nil : r.to_s)
49 end
49 end
50
50
51 # Returns the identifier of this changeset; depending on repository backends
51 # Returns the identifier of this changeset; depending on repository backends
52 def identifier
52 def identifier
53 if repository.class.respond_to? :changeset_identifier
53 if repository.class.respond_to? :changeset_identifier
54 repository.class.changeset_identifier self
54 repository.class.changeset_identifier self
55 else
55 else
56 revision.to_s
56 revision.to_s
57 end
57 end
58 end
58 end
59
59
60 def committed_on=(date)
60 def committed_on=(date)
61 self.commit_date = date
61 self.commit_date = date
62 super
62 super
63 end
63 end
64
64
65 # Returns the readable identifier
65 # Returns the readable identifier
66 def format_identifier
66 def format_identifier
67 if repository.class.respond_to? :format_changeset_identifier
67 if repository.class.respond_to? :format_changeset_identifier
68 repository.class.format_changeset_identifier self
68 repository.class.format_changeset_identifier self
69 else
69 else
70 identifier
70 identifier
71 end
71 end
72 end
72 end
73
73
74 def project
74 def project
75 repository.project
75 repository.project
76 end
76 end
77
77
78 def author
78 def author
79 user || committer.to_s.split('<').first
79 user || committer.to_s.split('<').first
80 end
80 end
81
81
82 def before_create
82 def before_create
83 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
83 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
84 self.comments = self.class.normalize_comments(self.comments, repository.repo_log_encoding)
84 self.comments = self.class.normalize_comments(self.comments, repository.repo_log_encoding)
85 self.user = repository.find_committer_user(self.committer)
85 self.user = repository.find_committer_user(self.committer)
86 end
86 end
87
87
88 def after_create
88 def after_create
89 scan_comment_for_issue_ids
89 scan_comment_for_issue_ids
90 end
90 end
91
91
92 TIMELOG_RE = /
92 TIMELOG_RE = /
93 (
93 (
94 ((\d+)(h|hours?))((\d+)(m|min)?)?
94 ((\d+)(h|hours?))((\d+)(m|min)?)?
95 |
95 |
96 ((\d+)(h|hours?|m|min))
96 ((\d+)(h|hours?|m|min))
97 |
97 |
98 (\d+):(\d+)
98 (\d+):(\d+)
99 |
99 |
100 (\d+([\.,]\d+)?)h?
100 (\d+([\.,]\d+)?)h?
101 )
101 )
102 /x
102 /x
103
103
104 def scan_comment_for_issue_ids
104 def scan_comment_for_issue_ids
105 return if comments.blank?
105 return if comments.blank?
106 # keywords used to reference issues
106 # keywords used to reference issues
107 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
107 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
108 ref_keywords_any = ref_keywords.delete('*')
108 ref_keywords_any = ref_keywords.delete('*')
109 # keywords used to fix issues
109 # keywords used to fix issues
110 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
110 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
111
111
112 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
112 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
113
113
114 referenced_issues = []
114 referenced_issues = []
115
115
116 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
116 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
117 action, refs = match[2], match[3]
117 action, refs = match[2], match[3]
118 next unless action.present? || ref_keywords_any
118 next unless action.present? || ref_keywords_any
119
119
120 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
120 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
121 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
121 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
122 if issue
122 if issue
123 referenced_issues << issue
123 referenced_issues << issue
124 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
124 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
125 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
125 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
126 end
126 end
127 end
127 end
128 end
128 end
129
129
130 referenced_issues.uniq!
130 referenced_issues.uniq!
131 self.issues = referenced_issues unless referenced_issues.empty?
131 self.issues = referenced_issues unless referenced_issues.empty?
132 end
132 end
133
133
134 def short_comments
134 def short_comments
135 @short_comments || split_comments.first
135 @short_comments || split_comments.first
136 end
136 end
137
137
138 def long_comments
138 def long_comments
139 @long_comments || split_comments.last
139 @long_comments || split_comments.last
140 end
140 end
141
141
142 def text_tag
142 def text_tag
143 if scmid?
143 if scmid?
144 "commit:#{scmid}"
144 "commit:#{scmid}"
145 else
145 else
146 "r#{revision}"
146 "r#{revision}"
147 end
147 end
148 end
148 end
149
149
150 # Returns the previous changeset
150 # Returns the previous changeset
151 def previous
151 def previous
152 @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
152 @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
153 end
153 end
154
154
155 # Returns the next changeset
155 # Returns the next changeset
156 def next
156 def next
157 @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
157 @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
158 end
158 end
159
159
160 # Creates a new Change from it's common parameters
160 # Creates a new Change from it's common parameters
161 def create_change(change)
161 def create_change(change)
162 Change.create(:changeset => self,
162 Change.create(:changeset => self,
163 :action => change[:action],
163 :action => change[:action],
164 :path => change[:path],
164 :path => change[:path],
165 :from_path => change[:from_path],
165 :from_path => change[:from_path],
166 :from_revision => change[:from_revision])
166 :from_revision => change[:from_revision])
167 end
167 end
168
168
169 private
169 private
170
170
171 # Finds an issue that can be referenced by the commit message
171 # Finds an issue that can be referenced by the commit message
172 # i.e. an issue that belong to the repository project, a subproject or a parent project
172 # i.e. an issue that belong to the repository project, a subproject or a parent project
173 def find_referenced_issue_by_id(id)
173 def find_referenced_issue_by_id(id)
174 return nil if id.blank?
174 return nil if id.blank?
175 issue = Issue.find_by_id(id.to_i, :include => :project)
175 issue = Issue.find_by_id(id.to_i, :include => :project)
176 if issue
176 if issue
177 unless issue.project && (project == issue.project || project.is_ancestor_of?(issue.project) || project.is_descendant_of?(issue.project))
177 unless issue.project && (project == issue.project || project.is_ancestor_of?(issue.project) || project.is_descendant_of?(issue.project))
178 issue = nil
178 issue = nil
179 end
179 end
180 end
180 end
181 issue
181 issue
182 end
182 end
183
183
184 def fix_issue(issue)
184 def fix_issue(issue)
185 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
185 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
186 if status.nil?
186 if status.nil?
187 logger.warn("No status macthes commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
187 logger.warn("No status macthes commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
188 return issue
188 return issue
189 end
189 end
190
190
191 # the issue may have been updated by the closure of another one (eg. duplicate)
191 # the issue may have been updated by the closure of another one (eg. duplicate)
192 issue.reload
192 issue.reload
193 # don't change the status is the issue is closed
193 # don't change the status is the issue is closed
194 return if issue.status && issue.status.is_closed?
194 return if issue.status && issue.status.is_closed?
195
195
196 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag))
196 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag))
197 issue.status = status
197 issue.status = status
198 unless Setting.commit_fix_done_ratio.blank?
198 unless Setting.commit_fix_done_ratio.blank?
199 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
199 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
200 end
200 end
201 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
201 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
202 { :changeset => self, :issue => issue })
202 { :changeset => self, :issue => issue })
203 unless issue.save
203 unless issue.save
204 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
204 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
205 end
205 end
206 issue
206 issue
207 end
207 end
208
208
209 def log_time(issue, hours)
209 def log_time(issue, hours)
210 time_entry = TimeEntry.new(
210 time_entry = TimeEntry.new(
211 :user => user,
211 :user => user,
212 :hours => hours,
212 :hours => hours,
213 :issue => issue,
213 :issue => issue,
214 :spent_on => commit_date,
214 :spent_on => commit_date,
215 :comments => l(:text_time_logged_by_changeset, :value => text_tag, :locale => Setting.default_language)
215 :comments => l(:text_time_logged_by_changeset, :value => text_tag, :locale => Setting.default_language)
216 )
216 )
217 time_entry.activity = log_time_activity unless log_time_activity.nil?
217 time_entry.activity = log_time_activity unless log_time_activity.nil?
218
218
219 unless time_entry.save
219 unless time_entry.save
220 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
220 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
221 end
221 end
222 time_entry
222 time_entry
223 end
223 end
224
224
225 def log_time_activity
225 def log_time_activity
226 if Setting.commit_logtime_activity_id.to_i > 0
226 if Setting.commit_logtime_activity_id.to_i > 0
227 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
227 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
228 end
228 end
229 end
229 end
230
230
231 def split_comments
231 def split_comments
232 comments =~ /\A(.+?)\r?\n(.*)$/m
232 comments =~ /\A(.+?)\r?\n(.*)$/m
233 @short_comments = $1 || comments
233 @short_comments = $1 || comments
234 @long_comments = $2.to_s.strip
234 @long_comments = $2.to_s.strip
235 return @short_comments, @long_comments
235 return @short_comments, @long_comments
236 end
236 end
237
237
238 public
238 public
239
239
240 # Strips and reencodes a commit log before insertion into the database
240 # Strips and reencodes a commit log before insertion into the database
241 def self.normalize_comments(str, encoding)
241 def self.normalize_comments(str, encoding)
242 Changeset.to_utf8(str.to_s.strip, encoding)
242 Changeset.to_utf8(str.to_s.strip, encoding)
243 end
243 end
244
244
245 private
245 private
246
246
247 def self.to_utf8(str, encoding)
247 def self.to_utf8(str, encoding)
248 return str if str.blank?
248 return str if str.blank?
249 unless encoding.blank? || encoding == 'UTF-8'
249 unless encoding.blank? || encoding == 'UTF-8'
250 begin
250 begin
251 str = Iconv.conv('UTF-8', encoding, str)
251 str = Iconv.conv('UTF-8', encoding, str)
252 rescue Iconv::Failure
252 rescue Iconv::Failure
253 # do nothing here
253 # do nothing here
254 end
254 end
255 end
255 end
256 if str.respond_to?(:force_encoding)
256 if str.respond_to?(:force_encoding)
257 str.force_encoding('UTF-8')
257 str.force_encoding('UTF-8')
258 if ! str.valid_encoding?
258 if ! str.valid_encoding?
259 str = str.encode("US-ASCII", :invalid => :replace,
259 str = str.encode("US-ASCII", :invalid => :replace,
260 :undef => :replace, :replace => '?').encode("UTF-8")
260 :undef => :replace, :replace => '?').encode("UTF-8")
261 end
261 end
262 else
262 else
263 # removes invalid UTF8 sequences
263 # removes invalid UTF8 sequences
264 begin
264 begin
265 str = Iconv.conv('UTF-8//IGNORE', 'UTF-8', str + ' ')[0..-3]
265 str = Iconv.conv('UTF-8//IGNORE', 'UTF-8', str + ' ')[0..-3]
266 rescue Iconv::InvalidEncoding
266 rescue Iconv::InvalidEncoding
267 # "UTF-8//IGNORE" is not supported on some OS
267 # "UTF-8//IGNORE" is not supported on some OS
268 end
268 end
269 end
269 end
270 str
270 str
271 end
271 end
272 end
272 end
@@ -1,52 +1,52
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Document < ActiveRecord::Base
18 class Document < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
20 belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
21 acts_as_attachable :delete_permission => :manage_documents
21 acts_as_attachable :delete_permission => :manage_documents
22
22
23 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
23 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
24 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
24 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
25 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
25 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
26 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
26 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
27 acts_as_activity_provider :find_options => {:include => :project}
27 acts_as_activity_provider :find_options => {:include => :project}
28
28
29 validates_presence_of :project, :title, :category
29 validates_presence_of :project, :title, :category
30 validates_length_of :title, :maximum => 60
30 validates_length_of :title, :maximum => 60
31
31
32 named_scope :visible, lambda {|*args| { :include => :project,
32 named_scope :visible, lambda {|*args| { :include => :project,
33 :conditions => Project.allowed_to_condition(args.first || User.current, :view_documents) } }
33 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
34
34
35 def visible?(user=User.current)
35 def visible?(user=User.current)
36 !user.nil? && user.allowed_to?(:view_documents, project)
36 !user.nil? && user.allowed_to?(:view_documents, project)
37 end
37 end
38
38
39 def after_initialize
39 def after_initialize
40 if new_record?
40 if new_record?
41 self.category ||= DocumentCategory.default
41 self.category ||= DocumentCategory.default
42 end
42 end
43 end
43 end
44
44
45 def updated_on
45 def updated_on
46 unless @updated_on
46 unless @updated_on
47 a = attachments.find(:first, :order => 'created_on DESC')
47 a = attachments.find(:first, :order => 'created_on DESC')
48 @updated_on = (a && a.created_on) || created_on
48 @updated_on = (a && a.created_on) || created_on
49 end
49 end
50 @updated_on
50 @updated_on
51 end
51 end
52 end
52 end
@@ -1,883 +1,883
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 belongs_to :project
21 belongs_to :project
22 belongs_to :tracker
22 belongs_to :tracker
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
25 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29
29
30 has_many :journals, :as => :journalized, :dependent => :destroy
30 has_many :journals, :as => :journalized, :dependent => :destroy
31 has_many :time_entries, :dependent => :delete_all
31 has_many :time_entries, :dependent => :delete_all
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33
33
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36
36
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 acts_as_attachable :after_remove => :attachment_removed
38 acts_as_attachable :after_remove => :attachment_removed
39 acts_as_customizable
39 acts_as_customizable
40 acts_as_watchable
40 acts_as_watchable
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 :include => [:project, :journals],
42 :include => [:project, :journals],
43 # sort by id so that limited eager loading doesn't break with postgresql
43 # sort by id so that limited eager loading doesn't break with postgresql
44 :order_column => "#{table_name}.id"
44 :order_column => "#{table_name}.id"
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48
48
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 :author_key => :author_id
50 :author_key => :author_id
51
51
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53
53
54 attr_reader :current_journal
54 attr_reader :current_journal
55
55
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57
57
58 validates_length_of :subject, :maximum => 255
58 validates_length_of :subject, :maximum => 255
59 validates_inclusion_of :done_ratio, :in => 0..100
59 validates_inclusion_of :done_ratio, :in => 0..100
60 validates_numericality_of :estimated_hours, :allow_nil => true
60 validates_numericality_of :estimated_hours, :allow_nil => true
61
61
62 named_scope :visible, lambda {|*args| { :include => :project,
62 named_scope :visible, lambda {|*args| { :include => :project,
63 :conditions => Issue.visible_condition(args.first || User.current) } }
63 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
64
64
65 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
65 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
66
66
67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
69 named_scope :on_active_project, :include => [:status, :project, :tracker],
69 named_scope :on_active_project, :include => [:status, :project, :tracker],
70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
71
71
72 named_scope :without_version, lambda {
72 named_scope :without_version, lambda {
73 {
73 {
74 :conditions => { :fixed_version_id => nil}
74 :conditions => { :fixed_version_id => nil}
75 }
75 }
76 }
76 }
77
77
78 named_scope :with_query, lambda {|query|
78 named_scope :with_query, lambda {|query|
79 {
79 {
80 :conditions => Query.merge_conditions(query.statement)
80 :conditions => Query.merge_conditions(query.statement)
81 }
81 }
82 }
82 }
83
83
84 before_create :default_assign
84 before_create :default_assign
85 before_save :close_duplicates, :update_done_ratio_from_issue_status
85 before_save :close_duplicates, :update_done_ratio_from_issue_status
86 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
86 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
87 after_destroy :update_parent_attributes
87 after_destroy :update_parent_attributes
88
88
89 # Returns a SQL conditions string used to find all issues visible by the specified user
89 # Returns a SQL conditions string used to find all issues visible by the specified user
90 def self.visible_condition(user, options={})
90 def self.visible_condition(user, options={})
91 Project.allowed_to_condition(user, :view_issues, options)
91 Project.allowed_to_condition(user, :view_issues, options)
92 end
92 end
93
93
94 # Returns true if usr or current user is allowed to view the issue
94 # Returns true if usr or current user is allowed to view the issue
95 def visible?(usr=nil)
95 def visible?(usr=nil)
96 (usr || User.current).allowed_to?(:view_issues, self.project)
96 (usr || User.current).allowed_to?(:view_issues, self.project)
97 end
97 end
98
98
99 def after_initialize
99 def after_initialize
100 if new_record?
100 if new_record?
101 # set default values for new records only
101 # set default values for new records only
102 self.status ||= IssueStatus.default
102 self.status ||= IssueStatus.default
103 self.priority ||= IssuePriority.default
103 self.priority ||= IssuePriority.default
104 end
104 end
105 end
105 end
106
106
107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
108 def available_custom_fields
108 def available_custom_fields
109 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
109 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
110 end
110 end
111
111
112 def copy_from(arg)
112 def copy_from(arg)
113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
116 self.status = issue.status
116 self.status = issue.status
117 self
117 self
118 end
118 end
119
119
120 # Moves/copies an issue to a new project and tracker
120 # Moves/copies an issue to a new project and tracker
121 # Returns the moved/copied issue on success, false on failure
121 # Returns the moved/copied issue on success, false on failure
122 def move_to_project(*args)
122 def move_to_project(*args)
123 ret = Issue.transaction do
123 ret = Issue.transaction do
124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
125 end || false
125 end || false
126 end
126 end
127
127
128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
129 options ||= {}
129 options ||= {}
130 issue = options[:copy] ? self.class.new.copy_from(self) : self
130 issue = options[:copy] ? self.class.new.copy_from(self) : self
131
131
132 if new_project && issue.project_id != new_project.id
132 if new_project && issue.project_id != new_project.id
133 # delete issue relations
133 # delete issue relations
134 unless Setting.cross_project_issue_relations?
134 unless Setting.cross_project_issue_relations?
135 issue.relations_from.clear
135 issue.relations_from.clear
136 issue.relations_to.clear
136 issue.relations_to.clear
137 end
137 end
138 # issue is moved to another project
138 # issue is moved to another project
139 # reassign to the category with same name if any
139 # reassign to the category with same name if any
140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
141 issue.category = new_category
141 issue.category = new_category
142 # Keep the fixed_version if it's still valid in the new_project
142 # Keep the fixed_version if it's still valid in the new_project
143 unless new_project.shared_versions.include?(issue.fixed_version)
143 unless new_project.shared_versions.include?(issue.fixed_version)
144 issue.fixed_version = nil
144 issue.fixed_version = nil
145 end
145 end
146 issue.project = new_project
146 issue.project = new_project
147 if issue.parent && issue.parent.project_id != issue.project_id
147 if issue.parent && issue.parent.project_id != issue.project_id
148 issue.parent_issue_id = nil
148 issue.parent_issue_id = nil
149 end
149 end
150 end
150 end
151 if new_tracker
151 if new_tracker
152 issue.tracker = new_tracker
152 issue.tracker = new_tracker
153 issue.reset_custom_values!
153 issue.reset_custom_values!
154 end
154 end
155 if options[:copy]
155 if options[:copy]
156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
157 issue.status = if options[:attributes] && options[:attributes][:status_id]
157 issue.status = if options[:attributes] && options[:attributes][:status_id]
158 IssueStatus.find_by_id(options[:attributes][:status_id])
158 IssueStatus.find_by_id(options[:attributes][:status_id])
159 else
159 else
160 self.status
160 self.status
161 end
161 end
162 end
162 end
163 # Allow bulk setting of attributes on the issue
163 # Allow bulk setting of attributes on the issue
164 if options[:attributes]
164 if options[:attributes]
165 issue.attributes = options[:attributes]
165 issue.attributes = options[:attributes]
166 end
166 end
167 if issue.save
167 if issue.save
168 unless options[:copy]
168 unless options[:copy]
169 # Manually update project_id on related time entries
169 # Manually update project_id on related time entries
170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
171
171
172 issue.children.each do |child|
172 issue.children.each do |child|
173 unless child.move_to_project_without_transaction(new_project)
173 unless child.move_to_project_without_transaction(new_project)
174 # Move failed and transaction was rollback'd
174 # Move failed and transaction was rollback'd
175 return false
175 return false
176 end
176 end
177 end
177 end
178 end
178 end
179 else
179 else
180 return false
180 return false
181 end
181 end
182 issue
182 issue
183 end
183 end
184
184
185 def status_id=(sid)
185 def status_id=(sid)
186 self.status = nil
186 self.status = nil
187 write_attribute(:status_id, sid)
187 write_attribute(:status_id, sid)
188 end
188 end
189
189
190 def priority_id=(pid)
190 def priority_id=(pid)
191 self.priority = nil
191 self.priority = nil
192 write_attribute(:priority_id, pid)
192 write_attribute(:priority_id, pid)
193 end
193 end
194
194
195 def tracker_id=(tid)
195 def tracker_id=(tid)
196 self.tracker = nil
196 self.tracker = nil
197 result = write_attribute(:tracker_id, tid)
197 result = write_attribute(:tracker_id, tid)
198 @custom_field_values = nil
198 @custom_field_values = nil
199 result
199 result
200 end
200 end
201
201
202 # Overrides attributes= so that tracker_id gets assigned first
202 # Overrides attributes= so that tracker_id gets assigned first
203 def attributes_with_tracker_first=(new_attributes, *args)
203 def attributes_with_tracker_first=(new_attributes, *args)
204 return if new_attributes.nil?
204 return if new_attributes.nil?
205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
206 if new_tracker_id
206 if new_tracker_id
207 self.tracker_id = new_tracker_id
207 self.tracker_id = new_tracker_id
208 end
208 end
209 send :attributes_without_tracker_first=, new_attributes, *args
209 send :attributes_without_tracker_first=, new_attributes, *args
210 end
210 end
211 # Do not redefine alias chain on reload (see #4838)
211 # Do not redefine alias chain on reload (see #4838)
212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
213
213
214 def estimated_hours=(h)
214 def estimated_hours=(h)
215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
216 end
216 end
217
217
218 safe_attributes 'tracker_id',
218 safe_attributes 'tracker_id',
219 'status_id',
219 'status_id',
220 'parent_issue_id',
220 'parent_issue_id',
221 'category_id',
221 'category_id',
222 'assigned_to_id',
222 'assigned_to_id',
223 'priority_id',
223 'priority_id',
224 'fixed_version_id',
224 'fixed_version_id',
225 'subject',
225 'subject',
226 'description',
226 'description',
227 'start_date',
227 'start_date',
228 'due_date',
228 'due_date',
229 'done_ratio',
229 'done_ratio',
230 'estimated_hours',
230 'estimated_hours',
231 'custom_field_values',
231 'custom_field_values',
232 'custom_fields',
232 'custom_fields',
233 'lock_version',
233 'lock_version',
234 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
234 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
235
235
236 safe_attributes 'status_id',
236 safe_attributes 'status_id',
237 'assigned_to_id',
237 'assigned_to_id',
238 'fixed_version_id',
238 'fixed_version_id',
239 'done_ratio',
239 'done_ratio',
240 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
240 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
241
241
242 # Safely sets attributes
242 # Safely sets attributes
243 # Should be called from controllers instead of #attributes=
243 # Should be called from controllers instead of #attributes=
244 # attr_accessible is too rough because we still want things like
244 # attr_accessible is too rough because we still want things like
245 # Issue.new(:project => foo) to work
245 # Issue.new(:project => foo) to work
246 # TODO: move workflow/permission checks from controllers to here
246 # TODO: move workflow/permission checks from controllers to here
247 def safe_attributes=(attrs, user=User.current)
247 def safe_attributes=(attrs, user=User.current)
248 return unless attrs.is_a?(Hash)
248 return unless attrs.is_a?(Hash)
249
249
250 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
250 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 attrs = delete_unsafe_attributes(attrs, user)
251 attrs = delete_unsafe_attributes(attrs, user)
252 return if attrs.empty?
252 return if attrs.empty?
253
253
254 # Tracker must be set before since new_statuses_allowed_to depends on it.
254 # Tracker must be set before since new_statuses_allowed_to depends on it.
255 if t = attrs.delete('tracker_id')
255 if t = attrs.delete('tracker_id')
256 self.tracker_id = t
256 self.tracker_id = t
257 end
257 end
258
258
259 if attrs['status_id']
259 if attrs['status_id']
260 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
260 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
261 attrs.delete('status_id')
261 attrs.delete('status_id')
262 end
262 end
263 end
263 end
264
264
265 unless leaf?
265 unless leaf?
266 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
266 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
267 end
267 end
268
268
269 if attrs.has_key?('parent_issue_id')
269 if attrs.has_key?('parent_issue_id')
270 if !user.allowed_to?(:manage_subtasks, project)
270 if !user.allowed_to?(:manage_subtasks, project)
271 attrs.delete('parent_issue_id')
271 attrs.delete('parent_issue_id')
272 elsif !attrs['parent_issue_id'].blank?
272 elsif !attrs['parent_issue_id'].blank?
273 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
273 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
274 end
274 end
275 end
275 end
276
276
277 self.attributes = attrs
277 self.attributes = attrs
278 end
278 end
279
279
280 def done_ratio
280 def done_ratio
281 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
281 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
282 status.default_done_ratio
282 status.default_done_ratio
283 else
283 else
284 read_attribute(:done_ratio)
284 read_attribute(:done_ratio)
285 end
285 end
286 end
286 end
287
287
288 def self.use_status_for_done_ratio?
288 def self.use_status_for_done_ratio?
289 Setting.issue_done_ratio == 'issue_status'
289 Setting.issue_done_ratio == 'issue_status'
290 end
290 end
291
291
292 def self.use_field_for_done_ratio?
292 def self.use_field_for_done_ratio?
293 Setting.issue_done_ratio == 'issue_field'
293 Setting.issue_done_ratio == 'issue_field'
294 end
294 end
295
295
296 def validate
296 def validate
297 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
297 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
298 errors.add :due_date, :not_a_date
298 errors.add :due_date, :not_a_date
299 end
299 end
300
300
301 if self.due_date and self.start_date and self.due_date < self.start_date
301 if self.due_date and self.start_date and self.due_date < self.start_date
302 errors.add :due_date, :greater_than_start_date
302 errors.add :due_date, :greater_than_start_date
303 end
303 end
304
304
305 if start_date && soonest_start && start_date < soonest_start
305 if start_date && soonest_start && start_date < soonest_start
306 errors.add :start_date, :invalid
306 errors.add :start_date, :invalid
307 end
307 end
308
308
309 if fixed_version
309 if fixed_version
310 if !assignable_versions.include?(fixed_version)
310 if !assignable_versions.include?(fixed_version)
311 errors.add :fixed_version_id, :inclusion
311 errors.add :fixed_version_id, :inclusion
312 elsif reopened? && fixed_version.closed?
312 elsif reopened? && fixed_version.closed?
313 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
313 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
314 end
314 end
315 end
315 end
316
316
317 # Checks that the issue can not be added/moved to a disabled tracker
317 # Checks that the issue can not be added/moved to a disabled tracker
318 if project && (tracker_id_changed? || project_id_changed?)
318 if project && (tracker_id_changed? || project_id_changed?)
319 unless project.trackers.include?(tracker)
319 unless project.trackers.include?(tracker)
320 errors.add :tracker_id, :inclusion
320 errors.add :tracker_id, :inclusion
321 end
321 end
322 end
322 end
323
323
324 # Checks parent issue assignment
324 # Checks parent issue assignment
325 if @parent_issue
325 if @parent_issue
326 if @parent_issue.project_id != project_id
326 if @parent_issue.project_id != project_id
327 errors.add :parent_issue_id, :not_same_project
327 errors.add :parent_issue_id, :not_same_project
328 elsif !new_record?
328 elsif !new_record?
329 # moving an existing issue
329 # moving an existing issue
330 if @parent_issue.root_id != root_id
330 if @parent_issue.root_id != root_id
331 # we can always move to another tree
331 # we can always move to another tree
332 elsif move_possible?(@parent_issue)
332 elsif move_possible?(@parent_issue)
333 # move accepted inside tree
333 # move accepted inside tree
334 else
334 else
335 errors.add :parent_issue_id, :not_a_valid_parent
335 errors.add :parent_issue_id, :not_a_valid_parent
336 end
336 end
337 end
337 end
338 end
338 end
339 end
339 end
340
340
341 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
341 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
342 # even if the user turns off the setting later
342 # even if the user turns off the setting later
343 def update_done_ratio_from_issue_status
343 def update_done_ratio_from_issue_status
344 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
344 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
345 self.done_ratio = status.default_done_ratio
345 self.done_ratio = status.default_done_ratio
346 end
346 end
347 end
347 end
348
348
349 def init_journal(user, notes = "")
349 def init_journal(user, notes = "")
350 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
350 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
351 @issue_before_change = self.clone
351 @issue_before_change = self.clone
352 @issue_before_change.status = self.status
352 @issue_before_change.status = self.status
353 @custom_values_before_change = {}
353 @custom_values_before_change = {}
354 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
354 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
355 # Make sure updated_on is updated when adding a note.
355 # Make sure updated_on is updated when adding a note.
356 updated_on_will_change!
356 updated_on_will_change!
357 @current_journal
357 @current_journal
358 end
358 end
359
359
360 # Return true if the issue is closed, otherwise false
360 # Return true if the issue is closed, otherwise false
361 def closed?
361 def closed?
362 self.status.is_closed?
362 self.status.is_closed?
363 end
363 end
364
364
365 # Return true if the issue is being reopened
365 # Return true if the issue is being reopened
366 def reopened?
366 def reopened?
367 if !new_record? && status_id_changed?
367 if !new_record? && status_id_changed?
368 status_was = IssueStatus.find_by_id(status_id_was)
368 status_was = IssueStatus.find_by_id(status_id_was)
369 status_new = IssueStatus.find_by_id(status_id)
369 status_new = IssueStatus.find_by_id(status_id)
370 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
370 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
371 return true
371 return true
372 end
372 end
373 end
373 end
374 false
374 false
375 end
375 end
376
376
377 # Return true if the issue is being closed
377 # Return true if the issue is being closed
378 def closing?
378 def closing?
379 if !new_record? && status_id_changed?
379 if !new_record? && status_id_changed?
380 status_was = IssueStatus.find_by_id(status_id_was)
380 status_was = IssueStatus.find_by_id(status_id_was)
381 status_new = IssueStatus.find_by_id(status_id)
381 status_new = IssueStatus.find_by_id(status_id)
382 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
382 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
383 return true
383 return true
384 end
384 end
385 end
385 end
386 false
386 false
387 end
387 end
388
388
389 # Returns true if the issue is overdue
389 # Returns true if the issue is overdue
390 def overdue?
390 def overdue?
391 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
391 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
392 end
392 end
393
393
394 # Is the amount of work done less than it should for the due date
394 # Is the amount of work done less than it should for the due date
395 def behind_schedule?
395 def behind_schedule?
396 return false if start_date.nil? || due_date.nil?
396 return false if start_date.nil? || due_date.nil?
397 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
397 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
398 return done_date <= Date.today
398 return done_date <= Date.today
399 end
399 end
400
400
401 # Does this issue have children?
401 # Does this issue have children?
402 def children?
402 def children?
403 !leaf?
403 !leaf?
404 end
404 end
405
405
406 # Users the issue can be assigned to
406 # Users the issue can be assigned to
407 def assignable_users
407 def assignable_users
408 users = project.assignable_users
408 users = project.assignable_users
409 users << author if author
409 users << author if author
410 users.uniq.sort
410 users.uniq.sort
411 end
411 end
412
412
413 # Versions that the issue can be assigned to
413 # Versions that the issue can be assigned to
414 def assignable_versions
414 def assignable_versions
415 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
415 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
416 end
416 end
417
417
418 # Returns true if this issue is blocked by another issue that is still open
418 # Returns true if this issue is blocked by another issue that is still open
419 def blocked?
419 def blocked?
420 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
420 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
421 end
421 end
422
422
423 # Returns an array of status that user is able to apply
423 # Returns an array of status that user is able to apply
424 def new_statuses_allowed_to(user, include_default=false)
424 def new_statuses_allowed_to(user, include_default=false)
425 statuses = status.find_new_statuses_allowed_to(
425 statuses = status.find_new_statuses_allowed_to(
426 user.roles_for_project(project),
426 user.roles_for_project(project),
427 tracker,
427 tracker,
428 author == user,
428 author == user,
429 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
429 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
430 )
430 )
431 statuses << status unless statuses.empty?
431 statuses << status unless statuses.empty?
432 statuses << IssueStatus.default if include_default
432 statuses << IssueStatus.default if include_default
433 statuses = statuses.uniq.sort
433 statuses = statuses.uniq.sort
434 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
434 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
435 end
435 end
436
436
437 # Returns the mail adresses of users that should be notified
437 # Returns the mail adresses of users that should be notified
438 def recipients
438 def recipients
439 notified = project.notified_users
439 notified = project.notified_users
440 # Author and assignee are always notified unless they have been
440 # Author and assignee are always notified unless they have been
441 # locked or don't want to be notified
441 # locked or don't want to be notified
442 notified << author if author && author.active? && author.notify_about?(self)
442 notified << author if author && author.active? && author.notify_about?(self)
443 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
443 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
444 notified.uniq!
444 notified.uniq!
445 # Remove users that can not view the issue
445 # Remove users that can not view the issue
446 notified.reject! {|user| !visible?(user)}
446 notified.reject! {|user| !visible?(user)}
447 notified.collect(&:mail)
447 notified.collect(&:mail)
448 end
448 end
449
449
450 # Returns the total number of hours spent on this issue and its descendants
450 # Returns the total number of hours spent on this issue and its descendants
451 #
451 #
452 # Example:
452 # Example:
453 # spent_hours => 0.0
453 # spent_hours => 0.0
454 # spent_hours => 50.2
454 # spent_hours => 50.2
455 def spent_hours
455 def spent_hours
456 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
456 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
457 end
457 end
458
458
459 def relations
459 def relations
460 (relations_from + relations_to).sort
460 (relations_from + relations_to).sort
461 end
461 end
462
462
463 def all_dependent_issues(except=[])
463 def all_dependent_issues(except=[])
464 except << self
464 except << self
465 dependencies = []
465 dependencies = []
466 relations_from.each do |relation|
466 relations_from.each do |relation|
467 if relation.issue_to && !except.include?(relation.issue_to)
467 if relation.issue_to && !except.include?(relation.issue_to)
468 dependencies << relation.issue_to
468 dependencies << relation.issue_to
469 dependencies += relation.issue_to.all_dependent_issues(except)
469 dependencies += relation.issue_to.all_dependent_issues(except)
470 end
470 end
471 end
471 end
472 dependencies
472 dependencies
473 end
473 end
474
474
475 # Returns an array of issues that duplicate this one
475 # Returns an array of issues that duplicate this one
476 def duplicates
476 def duplicates
477 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
477 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
478 end
478 end
479
479
480 # Returns the due date or the target due date if any
480 # Returns the due date or the target due date if any
481 # Used on gantt chart
481 # Used on gantt chart
482 def due_before
482 def due_before
483 due_date || (fixed_version ? fixed_version.effective_date : nil)
483 due_date || (fixed_version ? fixed_version.effective_date : nil)
484 end
484 end
485
485
486 # Returns the time scheduled for this issue.
486 # Returns the time scheduled for this issue.
487 #
487 #
488 # Example:
488 # Example:
489 # Start Date: 2/26/09, End Date: 3/04/09
489 # Start Date: 2/26/09, End Date: 3/04/09
490 # duration => 6
490 # duration => 6
491 def duration
491 def duration
492 (start_date && due_date) ? due_date - start_date : 0
492 (start_date && due_date) ? due_date - start_date : 0
493 end
493 end
494
494
495 def soonest_start
495 def soonest_start
496 @soonest_start ||= (
496 @soonest_start ||= (
497 relations_to.collect{|relation| relation.successor_soonest_start} +
497 relations_to.collect{|relation| relation.successor_soonest_start} +
498 ancestors.collect(&:soonest_start)
498 ancestors.collect(&:soonest_start)
499 ).compact.max
499 ).compact.max
500 end
500 end
501
501
502 def reschedule_after(date)
502 def reschedule_after(date)
503 return if date.nil?
503 return if date.nil?
504 if leaf?
504 if leaf?
505 if start_date.nil? || start_date < date
505 if start_date.nil? || start_date < date
506 self.start_date, self.due_date = date, date + duration
506 self.start_date, self.due_date = date, date + duration
507 save
507 save
508 end
508 end
509 else
509 else
510 leaves.each do |leaf|
510 leaves.each do |leaf|
511 leaf.reschedule_after(date)
511 leaf.reschedule_after(date)
512 end
512 end
513 end
513 end
514 end
514 end
515
515
516 def <=>(issue)
516 def <=>(issue)
517 if issue.nil?
517 if issue.nil?
518 -1
518 -1
519 elsif root_id != issue.root_id
519 elsif root_id != issue.root_id
520 (root_id || 0) <=> (issue.root_id || 0)
520 (root_id || 0) <=> (issue.root_id || 0)
521 else
521 else
522 (lft || 0) <=> (issue.lft || 0)
522 (lft || 0) <=> (issue.lft || 0)
523 end
523 end
524 end
524 end
525
525
526 def to_s
526 def to_s
527 "#{tracker} ##{id}: #{subject}"
527 "#{tracker} ##{id}: #{subject}"
528 end
528 end
529
529
530 # Returns a string of css classes that apply to the issue
530 # Returns a string of css classes that apply to the issue
531 def css_classes
531 def css_classes
532 s = "issue status-#{status.position} priority-#{priority.position}"
532 s = "issue status-#{status.position} priority-#{priority.position}"
533 s << ' closed' if closed?
533 s << ' closed' if closed?
534 s << ' overdue' if overdue?
534 s << ' overdue' if overdue?
535 s << ' child' if child?
535 s << ' child' if child?
536 s << ' parent' unless leaf?
536 s << ' parent' unless leaf?
537 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
537 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
538 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
538 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
539 s
539 s
540 end
540 end
541
541
542 # Saves an issue, time_entry, attachments, and a journal from the parameters
542 # Saves an issue, time_entry, attachments, and a journal from the parameters
543 # Returns false if save fails
543 # Returns false if save fails
544 def save_issue_with_child_records(params, existing_time_entry=nil)
544 def save_issue_with_child_records(params, existing_time_entry=nil)
545 Issue.transaction do
545 Issue.transaction do
546 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
546 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
547 @time_entry = existing_time_entry || TimeEntry.new
547 @time_entry = existing_time_entry || TimeEntry.new
548 @time_entry.project = project
548 @time_entry.project = project
549 @time_entry.issue = self
549 @time_entry.issue = self
550 @time_entry.user = User.current
550 @time_entry.user = User.current
551 @time_entry.spent_on = Date.today
551 @time_entry.spent_on = Date.today
552 @time_entry.attributes = params[:time_entry]
552 @time_entry.attributes = params[:time_entry]
553 self.time_entries << @time_entry
553 self.time_entries << @time_entry
554 end
554 end
555
555
556 if valid?
556 if valid?
557 attachments = Attachment.attach_files(self, params[:attachments])
557 attachments = Attachment.attach_files(self, params[:attachments])
558
558
559 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
559 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
560 # TODO: Rename hook
560 # TODO: Rename hook
561 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
561 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
562 begin
562 begin
563 if save
563 if save
564 # TODO: Rename hook
564 # TODO: Rename hook
565 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
565 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
566 else
566 else
567 raise ActiveRecord::Rollback
567 raise ActiveRecord::Rollback
568 end
568 end
569 rescue ActiveRecord::StaleObjectError
569 rescue ActiveRecord::StaleObjectError
570 attachments[:files].each(&:destroy)
570 attachments[:files].each(&:destroy)
571 errors.add_to_base l(:notice_locking_conflict)
571 errors.add_to_base l(:notice_locking_conflict)
572 raise ActiveRecord::Rollback
572 raise ActiveRecord::Rollback
573 end
573 end
574 end
574 end
575 end
575 end
576 end
576 end
577
577
578 # Unassigns issues from +version+ if it's no longer shared with issue's project
578 # Unassigns issues from +version+ if it's no longer shared with issue's project
579 def self.update_versions_from_sharing_change(version)
579 def self.update_versions_from_sharing_change(version)
580 # Update issues assigned to the version
580 # Update issues assigned to the version
581 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
581 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
582 end
582 end
583
583
584 # Unassigns issues from versions that are no longer shared
584 # Unassigns issues from versions that are no longer shared
585 # after +project+ was moved
585 # after +project+ was moved
586 def self.update_versions_from_hierarchy_change(project)
586 def self.update_versions_from_hierarchy_change(project)
587 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
587 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
588 # Update issues of the moved projects and issues assigned to a version of a moved project
588 # Update issues of the moved projects and issues assigned to a version of a moved project
589 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
589 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
590 end
590 end
591
591
592 def parent_issue_id=(arg)
592 def parent_issue_id=(arg)
593 parent_issue_id = arg.blank? ? nil : arg.to_i
593 parent_issue_id = arg.blank? ? nil : arg.to_i
594 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
594 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
595 @parent_issue.id
595 @parent_issue.id
596 else
596 else
597 @parent_issue = nil
597 @parent_issue = nil
598 nil
598 nil
599 end
599 end
600 end
600 end
601
601
602 def parent_issue_id
602 def parent_issue_id
603 if instance_variable_defined? :@parent_issue
603 if instance_variable_defined? :@parent_issue
604 @parent_issue.nil? ? nil : @parent_issue.id
604 @parent_issue.nil? ? nil : @parent_issue.id
605 else
605 else
606 parent_id
606 parent_id
607 end
607 end
608 end
608 end
609
609
610 # Extracted from the ReportsController.
610 # Extracted from the ReportsController.
611 def self.by_tracker(project)
611 def self.by_tracker(project)
612 count_and_group_by(:project => project,
612 count_and_group_by(:project => project,
613 :field => 'tracker_id',
613 :field => 'tracker_id',
614 :joins => Tracker.table_name)
614 :joins => Tracker.table_name)
615 end
615 end
616
616
617 def self.by_version(project)
617 def self.by_version(project)
618 count_and_group_by(:project => project,
618 count_and_group_by(:project => project,
619 :field => 'fixed_version_id',
619 :field => 'fixed_version_id',
620 :joins => Version.table_name)
620 :joins => Version.table_name)
621 end
621 end
622
622
623 def self.by_priority(project)
623 def self.by_priority(project)
624 count_and_group_by(:project => project,
624 count_and_group_by(:project => project,
625 :field => 'priority_id',
625 :field => 'priority_id',
626 :joins => IssuePriority.table_name)
626 :joins => IssuePriority.table_name)
627 end
627 end
628
628
629 def self.by_category(project)
629 def self.by_category(project)
630 count_and_group_by(:project => project,
630 count_and_group_by(:project => project,
631 :field => 'category_id',
631 :field => 'category_id',
632 :joins => IssueCategory.table_name)
632 :joins => IssueCategory.table_name)
633 end
633 end
634
634
635 def self.by_assigned_to(project)
635 def self.by_assigned_to(project)
636 count_and_group_by(:project => project,
636 count_and_group_by(:project => project,
637 :field => 'assigned_to_id',
637 :field => 'assigned_to_id',
638 :joins => User.table_name)
638 :joins => User.table_name)
639 end
639 end
640
640
641 def self.by_author(project)
641 def self.by_author(project)
642 count_and_group_by(:project => project,
642 count_and_group_by(:project => project,
643 :field => 'author_id',
643 :field => 'author_id',
644 :joins => User.table_name)
644 :joins => User.table_name)
645 end
645 end
646
646
647 def self.by_subproject(project)
647 def self.by_subproject(project)
648 ActiveRecord::Base.connection.select_all("select s.id as status_id,
648 ActiveRecord::Base.connection.select_all("select s.id as status_id,
649 s.is_closed as closed,
649 s.is_closed as closed,
650 i.project_id as project_id,
650 i.project_id as project_id,
651 count(i.id) as total
651 count(i.id) as total
652 from
652 from
653 #{Issue.table_name} i, #{IssueStatus.table_name} s
653 #{Issue.table_name} i, #{IssueStatus.table_name} s
654 where
654 where
655 i.status_id=s.id
655 i.status_id=s.id
656 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
656 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
657 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
657 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
658 end
658 end
659 # End ReportsController extraction
659 # End ReportsController extraction
660
660
661 # Returns an array of projects that current user can move issues to
661 # Returns an array of projects that current user can move issues to
662 def self.allowed_target_projects_on_move
662 def self.allowed_target_projects_on_move
663 projects = []
663 projects = []
664 if User.current.admin?
664 if User.current.admin?
665 # admin is allowed to move issues to any active (visible) project
665 # admin is allowed to move issues to any active (visible) project
666 projects = Project.visible.all
666 projects = Project.visible.all
667 elsif User.current.logged?
667 elsif User.current.logged?
668 if Role.non_member.allowed_to?(:move_issues)
668 if Role.non_member.allowed_to?(:move_issues)
669 projects = Project.visible.all
669 projects = Project.visible.all
670 else
670 else
671 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
671 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
672 end
672 end
673 end
673 end
674 projects
674 projects
675 end
675 end
676
676
677 private
677 private
678
678
679 def update_nested_set_attributes
679 def update_nested_set_attributes
680 if root_id.nil?
680 if root_id.nil?
681 # issue was just created
681 # issue was just created
682 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
682 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
683 set_default_left_and_right
683 set_default_left_and_right
684 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
684 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
685 if @parent_issue
685 if @parent_issue
686 move_to_child_of(@parent_issue)
686 move_to_child_of(@parent_issue)
687 end
687 end
688 reload
688 reload
689 elsif parent_issue_id != parent_id
689 elsif parent_issue_id != parent_id
690 former_parent_id = parent_id
690 former_parent_id = parent_id
691 # moving an existing issue
691 # moving an existing issue
692 if @parent_issue && @parent_issue.root_id == root_id
692 if @parent_issue && @parent_issue.root_id == root_id
693 # inside the same tree
693 # inside the same tree
694 move_to_child_of(@parent_issue)
694 move_to_child_of(@parent_issue)
695 else
695 else
696 # to another tree
696 # to another tree
697 unless root?
697 unless root?
698 move_to_right_of(root)
698 move_to_right_of(root)
699 reload
699 reload
700 end
700 end
701 old_root_id = root_id
701 old_root_id = root_id
702 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
702 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
703 target_maxright = nested_set_scope.maximum(right_column_name) || 0
703 target_maxright = nested_set_scope.maximum(right_column_name) || 0
704 offset = target_maxright + 1 - lft
704 offset = target_maxright + 1 - lft
705 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
705 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
706 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
706 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
707 self[left_column_name] = lft + offset
707 self[left_column_name] = lft + offset
708 self[right_column_name] = rgt + offset
708 self[right_column_name] = rgt + offset
709 if @parent_issue
709 if @parent_issue
710 move_to_child_of(@parent_issue)
710 move_to_child_of(@parent_issue)
711 end
711 end
712 end
712 end
713 reload
713 reload
714 # delete invalid relations of all descendants
714 # delete invalid relations of all descendants
715 self_and_descendants.each do |issue|
715 self_and_descendants.each do |issue|
716 issue.relations.each do |relation|
716 issue.relations.each do |relation|
717 relation.destroy unless relation.valid?
717 relation.destroy unless relation.valid?
718 end
718 end
719 end
719 end
720 # update former parent
720 # update former parent
721 recalculate_attributes_for(former_parent_id) if former_parent_id
721 recalculate_attributes_for(former_parent_id) if former_parent_id
722 end
722 end
723 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
723 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
724 end
724 end
725
725
726 def update_parent_attributes
726 def update_parent_attributes
727 recalculate_attributes_for(parent_id) if parent_id
727 recalculate_attributes_for(parent_id) if parent_id
728 end
728 end
729
729
730 def recalculate_attributes_for(issue_id)
730 def recalculate_attributes_for(issue_id)
731 if issue_id && p = Issue.find_by_id(issue_id)
731 if issue_id && p = Issue.find_by_id(issue_id)
732 # priority = highest priority of children
732 # priority = highest priority of children
733 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
733 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
734 p.priority = IssuePriority.find_by_position(priority_position)
734 p.priority = IssuePriority.find_by_position(priority_position)
735 end
735 end
736
736
737 # start/due dates = lowest/highest dates of children
737 # start/due dates = lowest/highest dates of children
738 p.start_date = p.children.minimum(:start_date)
738 p.start_date = p.children.minimum(:start_date)
739 p.due_date = p.children.maximum(:due_date)
739 p.due_date = p.children.maximum(:due_date)
740 if p.start_date && p.due_date && p.due_date < p.start_date
740 if p.start_date && p.due_date && p.due_date < p.start_date
741 p.start_date, p.due_date = p.due_date, p.start_date
741 p.start_date, p.due_date = p.due_date, p.start_date
742 end
742 end
743
743
744 # done ratio = weighted average ratio of leaves
744 # done ratio = weighted average ratio of leaves
745 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
745 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
746 leaves_count = p.leaves.count
746 leaves_count = p.leaves.count
747 if leaves_count > 0
747 if leaves_count > 0
748 average = p.leaves.average(:estimated_hours).to_f
748 average = p.leaves.average(:estimated_hours).to_f
749 if average == 0
749 if average == 0
750 average = 1
750 average = 1
751 end
751 end
752 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
752 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
753 progress = done / (average * leaves_count)
753 progress = done / (average * leaves_count)
754 p.done_ratio = progress.round
754 p.done_ratio = progress.round
755 end
755 end
756 end
756 end
757
757
758 # estimate = sum of leaves estimates
758 # estimate = sum of leaves estimates
759 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
759 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
760 p.estimated_hours = nil if p.estimated_hours == 0.0
760 p.estimated_hours = nil if p.estimated_hours == 0.0
761
761
762 # ancestors will be recursively updated
762 # ancestors will be recursively updated
763 p.save(false)
763 p.save(false)
764 end
764 end
765 end
765 end
766
766
767 # Update issues so their versions are not pointing to a
767 # Update issues so their versions are not pointing to a
768 # fixed_version that is not shared with the issue's project
768 # fixed_version that is not shared with the issue's project
769 def self.update_versions(conditions=nil)
769 def self.update_versions(conditions=nil)
770 # Only need to update issues with a fixed_version from
770 # Only need to update issues with a fixed_version from
771 # a different project and that is not systemwide shared
771 # a different project and that is not systemwide shared
772 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
772 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
773 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
773 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
774 " AND #{Version.table_name}.sharing <> 'system'",
774 " AND #{Version.table_name}.sharing <> 'system'",
775 conditions),
775 conditions),
776 :include => [:project, :fixed_version]
776 :include => [:project, :fixed_version]
777 ).each do |issue|
777 ).each do |issue|
778 next if issue.project.nil? || issue.fixed_version.nil?
778 next if issue.project.nil? || issue.fixed_version.nil?
779 unless issue.project.shared_versions.include?(issue.fixed_version)
779 unless issue.project.shared_versions.include?(issue.fixed_version)
780 issue.init_journal(User.current)
780 issue.init_journal(User.current)
781 issue.fixed_version = nil
781 issue.fixed_version = nil
782 issue.save
782 issue.save
783 end
783 end
784 end
784 end
785 end
785 end
786
786
787 # Callback on attachment deletion
787 # Callback on attachment deletion
788 def attachment_removed(obj)
788 def attachment_removed(obj)
789 journal = init_journal(User.current)
789 journal = init_journal(User.current)
790 journal.details << JournalDetail.new(:property => 'attachment',
790 journal.details << JournalDetail.new(:property => 'attachment',
791 :prop_key => obj.id,
791 :prop_key => obj.id,
792 :old_value => obj.filename)
792 :old_value => obj.filename)
793 journal.save
793 journal.save
794 end
794 end
795
795
796 # Default assignment based on category
796 # Default assignment based on category
797 def default_assign
797 def default_assign
798 if assigned_to.nil? && category && category.assigned_to
798 if assigned_to.nil? && category && category.assigned_to
799 self.assigned_to = category.assigned_to
799 self.assigned_to = category.assigned_to
800 end
800 end
801 end
801 end
802
802
803 # Updates start/due dates of following issues
803 # Updates start/due dates of following issues
804 def reschedule_following_issues
804 def reschedule_following_issues
805 if start_date_changed? || due_date_changed?
805 if start_date_changed? || due_date_changed?
806 relations_from.each do |relation|
806 relations_from.each do |relation|
807 relation.set_issue_to_dates
807 relation.set_issue_to_dates
808 end
808 end
809 end
809 end
810 end
810 end
811
811
812 # Closes duplicates if the issue is being closed
812 # Closes duplicates if the issue is being closed
813 def close_duplicates
813 def close_duplicates
814 if closing?
814 if closing?
815 duplicates.each do |duplicate|
815 duplicates.each do |duplicate|
816 # Reload is need in case the duplicate was updated by a previous duplicate
816 # Reload is need in case the duplicate was updated by a previous duplicate
817 duplicate.reload
817 duplicate.reload
818 # Don't re-close it if it's already closed
818 # Don't re-close it if it's already closed
819 next if duplicate.closed?
819 next if duplicate.closed?
820 # Same user and notes
820 # Same user and notes
821 if @current_journal
821 if @current_journal
822 duplicate.init_journal(@current_journal.user, @current_journal.notes)
822 duplicate.init_journal(@current_journal.user, @current_journal.notes)
823 end
823 end
824 duplicate.update_attribute :status, self.status
824 duplicate.update_attribute :status, self.status
825 end
825 end
826 end
826 end
827 end
827 end
828
828
829 # Saves the changes in a Journal
829 # Saves the changes in a Journal
830 # Called after_save
830 # Called after_save
831 def create_journal
831 def create_journal
832 if @current_journal
832 if @current_journal
833 # attributes changes
833 # attributes changes
834 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
834 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
835 @current_journal.details << JournalDetail.new(:property => 'attr',
835 @current_journal.details << JournalDetail.new(:property => 'attr',
836 :prop_key => c,
836 :prop_key => c,
837 :old_value => @issue_before_change.send(c),
837 :old_value => @issue_before_change.send(c),
838 :value => send(c)) unless send(c)==@issue_before_change.send(c)
838 :value => send(c)) unless send(c)==@issue_before_change.send(c)
839 }
839 }
840 # custom fields changes
840 # custom fields changes
841 custom_values.each {|c|
841 custom_values.each {|c|
842 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
842 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
843 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
843 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
844 @current_journal.details << JournalDetail.new(:property => 'cf',
844 @current_journal.details << JournalDetail.new(:property => 'cf',
845 :prop_key => c.custom_field_id,
845 :prop_key => c.custom_field_id,
846 :old_value => @custom_values_before_change[c.custom_field_id],
846 :old_value => @custom_values_before_change[c.custom_field_id],
847 :value => c.value)
847 :value => c.value)
848 }
848 }
849 @current_journal.save
849 @current_journal.save
850 # reset current journal
850 # reset current journal
851 init_journal @current_journal.user, @current_journal.notes
851 init_journal @current_journal.user, @current_journal.notes
852 end
852 end
853 end
853 end
854
854
855 # Query generator for selecting groups of issue counts for a project
855 # Query generator for selecting groups of issue counts for a project
856 # based on specific criteria
856 # based on specific criteria
857 #
857 #
858 # Options
858 # Options
859 # * project - Project to search in.
859 # * project - Project to search in.
860 # * field - String. Issue field to key off of in the grouping.
860 # * field - String. Issue field to key off of in the grouping.
861 # * joins - String. The table name to join against.
861 # * joins - String. The table name to join against.
862 def self.count_and_group_by(options)
862 def self.count_and_group_by(options)
863 project = options.delete(:project)
863 project = options.delete(:project)
864 select_field = options.delete(:field)
864 select_field = options.delete(:field)
865 joins = options.delete(:joins)
865 joins = options.delete(:joins)
866
866
867 where = "i.#{select_field}=j.id"
867 where = "i.#{select_field}=j.id"
868
868
869 ActiveRecord::Base.connection.select_all("select s.id as status_id,
869 ActiveRecord::Base.connection.select_all("select s.id as status_id,
870 s.is_closed as closed,
870 s.is_closed as closed,
871 j.id as #{select_field},
871 j.id as #{select_field},
872 count(i.id) as total
872 count(i.id) as total
873 from
873 from
874 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
874 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
875 where
875 where
876 i.status_id=s.id
876 i.status_id=s.id
877 and #{where}
877 and #{where}
878 and i.project_id=#{project.id}
878 and i.project_id=#{project.id}
879 group by s.id, s.is_closed, j.id")
879 group by s.id, s.is_closed, j.id")
880 end
880 end
881
881
882
882
883 end
883 end
@@ -1,81 +1,81
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Journal < ActiveRecord::Base
18 class Journal < ActiveRecord::Base
19 belongs_to :journalized, :polymorphic => true
19 belongs_to :journalized, :polymorphic => true
20 # added as a quick fix to allow eager loading of the polymorphic association
20 # added as a quick fix to allow eager loading of the polymorphic association
21 # since always associated to an issue, for now
21 # since always associated to an issue, for now
22 belongs_to :issue, :foreign_key => :journalized_id
22 belongs_to :issue, :foreign_key => :journalized_id
23
23
24 belongs_to :user
24 belongs_to :user
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
26 attr_accessor :indice
26 attr_accessor :indice
27
27
28 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
28 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
29 :description => :notes,
29 :description => :notes,
30 :author => :user,
30 :author => :user,
31 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
31 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
32 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
32 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
33
33
34 acts_as_activity_provider :type => 'issues',
34 acts_as_activity_provider :type => 'issues',
35 :permission => :view_issues,
35 :permission => :view_issues,
36 :author_key => :user_id,
36 :author_key => :user_id,
37 :find_options => {:include => [{:issue => :project}, :details, :user],
37 :find_options => {:include => [{:issue => :project}, :details, :user],
38 :conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
38 :conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
39 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
39 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
40
40
41 named_scope :visible, lambda {|*args| {
41 named_scope :visible, lambda {|*args| {
42 :include => {:issue => :project},
42 :include => {:issue => :project},
43 :conditions => Issue.visible_condition(args.first || User.current)
43 :conditions => Issue.visible_condition(args.shift || User.current, *args)
44 }}
44 }}
45
45
46 def save(*args)
46 def save(*args)
47 # Do not save an empty journal
47 # Do not save an empty journal
48 (details.empty? && notes.blank?) ? false : super
48 (details.empty? && notes.blank?) ? false : super
49 end
49 end
50
50
51 # Returns the new status if the journal contains a status change, otherwise nil
51 # Returns the new status if the journal contains a status change, otherwise nil
52 def new_status
52 def new_status
53 c = details.detect {|detail| detail.prop_key == 'status_id'}
53 c = details.detect {|detail| detail.prop_key == 'status_id'}
54 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
54 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
55 end
55 end
56
56
57 def new_value_for(prop)
57 def new_value_for(prop)
58 c = details.detect {|detail| detail.prop_key == prop}
58 c = details.detect {|detail| detail.prop_key == prop}
59 c ? c.value : nil
59 c ? c.value : nil
60 end
60 end
61
61
62 def editable_by?(usr)
62 def editable_by?(usr)
63 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
63 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
64 end
64 end
65
65
66 def project
66 def project
67 journalized.respond_to?(:project) ? journalized.project : nil
67 journalized.respond_to?(:project) ? journalized.project : nil
68 end
68 end
69
69
70 def attachments
70 def attachments
71 journalized.respond_to?(:attachments) ? journalized.attachments : nil
71 journalized.respond_to?(:attachments) ? journalized.attachments : nil
72 end
72 end
73
73
74 # Returns a string of css classes
74 # Returns a string of css classes
75 def css_classes
75 def css_classes
76 s = 'journal'
76 s = 'journal'
77 s << ' has-notes' unless notes.blank?
77 s << ' has-notes' unless notes.blank?
78 s << ' has-details' unless details.blank?
78 s << ' has-details' unless details.blank?
79 s
79 s
80 end
80 end
81 end
81 end
@@ -1,101 +1,101
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Message < ActiveRecord::Base
18 class Message < ActiveRecord::Base
19 belongs_to :board
19 belongs_to :board
20 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
20 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
21 acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
21 acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
22 acts_as_attachable
22 acts_as_attachable
23 belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
23 belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
24
24
25 acts_as_searchable :columns => ['subject', 'content'],
25 acts_as_searchable :columns => ['subject', 'content'],
26 :include => {:board => :project},
26 :include => {:board => :project},
27 :project_key => 'project_id',
27 :project_key => "#{Board.table_name}.project_id",
28 :date_column => "#{table_name}.created_on"
28 :date_column => "#{table_name}.created_on"
29 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
29 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
30 :description => :content,
30 :description => :content,
31 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
31 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
32 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
32 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
33 {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
33 {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
34
34
35 acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
35 acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
36 :author_key => :author_id
36 :author_key => :author_id
37 acts_as_watchable
37 acts_as_watchable
38
38
39 attr_protected :locked, :sticky
39 attr_protected :locked, :sticky
40 validates_presence_of :board, :subject, :content
40 validates_presence_of :board, :subject, :content
41 validates_length_of :subject, :maximum => 255
41 validates_length_of :subject, :maximum => 255
42
42
43 after_create :add_author_as_watcher
43 after_create :add_author_as_watcher
44
44
45 named_scope :visible, lambda {|*args| { :include => {:board => :project},
45 named_scope :visible, lambda {|*args| { :include => {:board => :project},
46 :conditions => Project.allowed_to_condition(args.first || User.current, :view_messages) } }
46 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
47
47
48 def visible?(user=User.current)
48 def visible?(user=User.current)
49 !user.nil? && user.allowed_to?(:view_messages, project)
49 !user.nil? && user.allowed_to?(:view_messages, project)
50 end
50 end
51
51
52 def validate_on_create
52 def validate_on_create
53 # Can not reply to a locked topic
53 # Can not reply to a locked topic
54 errors.add_to_base 'Topic is locked' if root.locked? && self != root
54 errors.add_to_base 'Topic is locked' if root.locked? && self != root
55 end
55 end
56
56
57 def after_create
57 def after_create
58 if parent
58 if parent
59 parent.reload.update_attribute(:last_reply_id, self.id)
59 parent.reload.update_attribute(:last_reply_id, self.id)
60 end
60 end
61 board.reset_counters!
61 board.reset_counters!
62 end
62 end
63
63
64 def after_update
64 def after_update
65 if board_id_changed?
65 if board_id_changed?
66 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
66 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
67 Board.reset_counters!(board_id_was)
67 Board.reset_counters!(board_id_was)
68 Board.reset_counters!(board_id)
68 Board.reset_counters!(board_id)
69 end
69 end
70 end
70 end
71
71
72 def after_destroy
72 def after_destroy
73 board.reset_counters!
73 board.reset_counters!
74 end
74 end
75
75
76 def sticky=(arg)
76 def sticky=(arg)
77 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
77 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
78 end
78 end
79
79
80 def sticky?
80 def sticky?
81 sticky == 1
81 sticky == 1
82 end
82 end
83
83
84 def project
84 def project
85 board.project
85 board.project
86 end
86 end
87
87
88 def editable_by?(usr)
88 def editable_by?(usr)
89 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
89 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
90 end
90 end
91
91
92 def destroyable_by?(usr)
92 def destroyable_by?(usr)
93 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
93 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
94 end
94 end
95
95
96 private
96 private
97
97
98 def add_author_as_watcher
98 def add_author_as_watcher
99 Watcher.create(:watchable => self.root, :user => author)
99 Watcher.create(:watchable => self.root, :user => author)
100 end
100 end
101 end
101 end
@@ -1,54 +1,54
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class News < ActiveRecord::Base
18 class News < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
20 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
21 has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on"
21 has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on"
22
22
23 validates_presence_of :title, :description
23 validates_presence_of :title, :description
24 validates_length_of :title, :maximum => 60
24 validates_length_of :title, :maximum => 60
25 validates_length_of :summary, :maximum => 255
25 validates_length_of :summary, :maximum => 255
26
26
27 acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
27 acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
28 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
28 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
29 acts_as_activity_provider :find_options => {:include => [:project, :author]},
29 acts_as_activity_provider :find_options => {:include => [:project, :author]},
30 :author_key => :author_id
30 :author_key => :author_id
31 acts_as_watchable
31 acts_as_watchable
32
32
33 after_create :add_author_as_watcher
33 after_create :add_author_as_watcher
34
34
35 named_scope :visible, lambda {|*args| {
35 named_scope :visible, lambda {|*args| {
36 :include => :project,
36 :include => :project,
37 :conditions => Project.allowed_to_condition(args.first || User.current, :view_news)
37 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_news, *args)
38 }}
38 }}
39
39
40 def visible?(user=User.current)
40 def visible?(user=User.current)
41 !user.nil? && user.allowed_to?(:view_news, project)
41 !user.nil? && user.allowed_to?(:view_news, project)
42 end
42 end
43
43
44 # returns latest news for projects visible by user
44 # returns latest news for projects visible by user
45 def self.latest(user = User.current, count = 5)
45 def self.latest(user = User.current, count = 5)
46 find(:all, :limit => count, :conditions => Project.allowed_to_condition(user, :view_news), :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
46 find(:all, :limit => count, :conditions => Project.allowed_to_condition(user, :view_news), :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
47 end
47 end
48
48
49 private
49 private
50
50
51 def add_author_as_watcher
51 def add_author_as_watcher
52 Watcher.create(:watchable => self, :user => author)
52 Watcher.create(:watchable => self, :user => author)
53 end
53 end
54 end
54 end
@@ -1,845 +1,850
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Project < ActiveRecord::Base
18 class Project < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 # Project statuses
21 # Project statuses
22 STATUS_ACTIVE = 1
22 STATUS_ACTIVE = 1
23 STATUS_ARCHIVED = 9
23 STATUS_ARCHIVED = 9
24
24
25 # Maximum length for project identifiers
25 # Maximum length for project identifiers
26 IDENTIFIER_MAX_LENGTH = 100
26 IDENTIFIER_MAX_LENGTH = 100
27
27
28 # Specific overidden Activities
28 # Specific overidden Activities
29 has_many :time_entry_activities
29 has_many :time_entry_activities
30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
31 has_many :memberships, :class_name => 'Member'
31 has_many :memberships, :class_name => 'Member'
32 has_many :member_principals, :class_name => 'Member',
32 has_many :member_principals, :class_name => 'Member',
33 :include => :principal,
33 :include => :principal,
34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
35 has_many :users, :through => :members
35 has_many :users, :through => :members
36 has_many :principals, :through => :member_principals, :source => :principal
36 has_many :principals, :through => :member_principals, :source => :principal
37
37
38 has_many :enabled_modules, :dependent => :delete_all
38 has_many :enabled_modules, :dependent => :delete_all
39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
40 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
40 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
41 has_many :issue_changes, :through => :issues, :source => :journals
41 has_many :issue_changes, :through => :issues, :source => :journals
42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
43 has_many :time_entries, :dependent => :delete_all
43 has_many :time_entries, :dependent => :delete_all
44 has_many :queries, :dependent => :delete_all
44 has_many :queries, :dependent => :delete_all
45 has_many :documents, :dependent => :destroy
45 has_many :documents, :dependent => :destroy
46 has_many :news, :dependent => :destroy, :include => :author
46 has_many :news, :dependent => :destroy, :include => :author
47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
48 has_many :boards, :dependent => :destroy, :order => "position ASC"
48 has_many :boards, :dependent => :destroy, :order => "position ASC"
49 has_one :repository, :dependent => :destroy
49 has_one :repository, :dependent => :destroy
50 has_many :changesets, :through => :repository
50 has_many :changesets, :through => :repository
51 has_one :wiki, :dependent => :destroy
51 has_one :wiki, :dependent => :destroy
52 # Custom field for the project issues
52 # Custom field for the project issues
53 has_and_belongs_to_many :issue_custom_fields,
53 has_and_belongs_to_many :issue_custom_fields,
54 :class_name => 'IssueCustomField',
54 :class_name => 'IssueCustomField',
55 :order => "#{CustomField.table_name}.position",
55 :order => "#{CustomField.table_name}.position",
56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 :association_foreign_key => 'custom_field_id'
57 :association_foreign_key => 'custom_field_id'
58
58
59 acts_as_nested_set :order => 'name', :dependent => :destroy
59 acts_as_nested_set :order => 'name', :dependent => :destroy
60 acts_as_attachable :view_permission => :view_files,
60 acts_as_attachable :view_permission => :view_files,
61 :delete_permission => :manage_files
61 :delete_permission => :manage_files
62
62
63 acts_as_customizable
63 acts_as_customizable
64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 :author => nil
67 :author => nil
68
68
69 attr_protected :status
69 attr_protected :status
70
70
71 validates_presence_of :name, :identifier
71 validates_presence_of :name, :identifier
72 validates_uniqueness_of :identifier
72 validates_uniqueness_of :identifier
73 validates_associated :repository, :wiki
73 validates_associated :repository, :wiki
74 validates_length_of :name, :maximum => 255
74 validates_length_of :name, :maximum => 255
75 validates_length_of :homepage, :maximum => 255
75 validates_length_of :homepage, :maximum => 255
76 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
76 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
77 # donwcase letters, digits, dashes but not digits only
77 # donwcase letters, digits, dashes but not digits only
78 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
78 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
79 # reserved words
79 # reserved words
80 validates_exclusion_of :identifier, :in => %w( new )
80 validates_exclusion_of :identifier, :in => %w( new )
81
81
82 before_destroy :delete_all_members
82 before_destroy :delete_all_members
83
83
84 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
84 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
85 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
85 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
86 named_scope :all_public, { :conditions => { :is_public => true } }
86 named_scope :all_public, { :conditions => { :is_public => true } }
87 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
87 named_scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
88
88
89 def initialize(attributes = nil)
89 def initialize(attributes = nil)
90 super
90 super
91
91
92 initialized = (attributes || {}).stringify_keys
92 initialized = (attributes || {}).stringify_keys
93 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
93 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
94 self.identifier = Project.next_identifier
94 self.identifier = Project.next_identifier
95 end
95 end
96 if !initialized.key?('is_public')
96 if !initialized.key?('is_public')
97 self.is_public = Setting.default_projects_public?
97 self.is_public = Setting.default_projects_public?
98 end
98 end
99 if !initialized.key?('enabled_module_names')
99 if !initialized.key?('enabled_module_names')
100 self.enabled_module_names = Setting.default_projects_modules
100 self.enabled_module_names = Setting.default_projects_modules
101 end
101 end
102 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
102 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
103 self.trackers = Tracker.all
103 self.trackers = Tracker.all
104 end
104 end
105 end
105 end
106
106
107 def identifier=(identifier)
107 def identifier=(identifier)
108 super unless identifier_frozen?
108 super unless identifier_frozen?
109 end
109 end
110
110
111 def identifier_frozen?
111 def identifier_frozen?
112 errors[:identifier].nil? && !(new_record? || identifier.blank?)
112 errors[:identifier].nil? && !(new_record? || identifier.blank?)
113 end
113 end
114
114
115 # returns latest created projects
115 # returns latest created projects
116 # non public projects will be returned only if user is a member of those
116 # non public projects will be returned only if user is a member of those
117 def self.latest(user=nil, count=5)
117 def self.latest(user=nil, count=5)
118 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
118 visible(user).find(:all, :limit => count, :order => "created_on DESC")
119 end
119 end
120
120
121 # Returns a SQL :conditions string used to find all active projects for the specified user.
121 def self.visible_by(user=nil)
122 ActiveSupport::Deprecation.warn "Project.visible_by is deprecated and will be removed in Redmine 1.3.0. Use Project.visible_condition instead."
123 visible_condition(user || User.current)
124 end
125
126 # Returns a SQL conditions string used to find all projects visible by the specified user.
122 #
127 #
123 # Examples:
128 # Examples:
124 # Projects.visible_by(admin) => "projects.status = 1"
129 # Project.visible_condition(admin) => "projects.status = 1"
125 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
130 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
126 def self.visible_by(user=nil)
131 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
127 user ||= User.current
132 def self.visible_condition(user, options={})
128 if user && user.admin?
133 allowed_to_condition(user, :view_project, options)
129 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
130 elsif user && user.memberships.any?
131 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
132 else
133 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
134 end
135 end
134 end
136
135
136 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
137 #
138 # Valid options:
139 # * :project => limit the condition to project
140 # * :with_subprojects => limit the condition to project and its subprojects
141 # * :member => limit the condition to the user projects
137 def self.allowed_to_condition(user, permission, options={})
142 def self.allowed_to_condition(user, permission, options={})
138 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
143 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
139 if perm = Redmine::AccessControl.permission(permission)
144 if perm = Redmine::AccessControl.permission(permission)
140 unless perm.project_module.nil?
145 unless perm.project_module.nil?
141 # If the permission belongs to a project module, make sure the module is enabled
146 # If the permission belongs to a project module, make sure the module is enabled
142 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
147 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
143 end
148 end
144 end
149 end
145 if options[:project]
150 if options[:project]
146 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
151 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
147 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
152 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
148 base_statement = "(#{project_statement}) AND (#{base_statement})"
153 base_statement = "(#{project_statement}) AND (#{base_statement})"
149 end
154 end
150
155
151 if user.admin?
156 if user.admin?
152 base_statement
157 base_statement
153 else
158 else
154 statement_by_role = {}
159 statement_by_role = {}
155 if user.logged?
160 if user.logged?
156 if Role.non_member.allowed_to?(permission) && !options[:member]
161 if Role.non_member.allowed_to?(permission) && !options[:member]
157 statement_by_role[Role.non_member] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
162 statement_by_role[Role.non_member] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
158 end
163 end
159 user.projects_by_role.each do |role, projects|
164 user.projects_by_role.each do |role, projects|
160 if role.allowed_to?(permission)
165 if role.allowed_to?(permission)
161 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
166 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
162 end
167 end
163 end
168 end
164 else
169 else
165 if Role.anonymous.allowed_to?(permission) && !options[:member]
170 if Role.anonymous.allowed_to?(permission) && !options[:member]
166 statement_by_role[Role.anonymous] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
171 statement_by_role[Role.anonymous] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
167 end
172 end
168 end
173 end
169 if statement_by_role.empty?
174 if statement_by_role.empty?
170 "1=0"
175 "1=0"
171 else
176 else
172 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
177 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
173 end
178 end
174 end
179 end
175 end
180 end
176
181
177 # Returns the Systemwide and project specific activities
182 # Returns the Systemwide and project specific activities
178 def activities(include_inactive=false)
183 def activities(include_inactive=false)
179 if include_inactive
184 if include_inactive
180 return all_activities
185 return all_activities
181 else
186 else
182 return active_activities
187 return active_activities
183 end
188 end
184 end
189 end
185
190
186 # Will create a new Project specific Activity or update an existing one
191 # Will create a new Project specific Activity or update an existing one
187 #
192 #
188 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
193 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
189 # does not successfully save.
194 # does not successfully save.
190 def update_or_create_time_entry_activity(id, activity_hash)
195 def update_or_create_time_entry_activity(id, activity_hash)
191 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
196 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
192 self.create_time_entry_activity_if_needed(activity_hash)
197 self.create_time_entry_activity_if_needed(activity_hash)
193 else
198 else
194 activity = project.time_entry_activities.find_by_id(id.to_i)
199 activity = project.time_entry_activities.find_by_id(id.to_i)
195 activity.update_attributes(activity_hash) if activity
200 activity.update_attributes(activity_hash) if activity
196 end
201 end
197 end
202 end
198
203
199 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
204 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
200 #
205 #
201 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
206 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
202 # does not successfully save.
207 # does not successfully save.
203 def create_time_entry_activity_if_needed(activity)
208 def create_time_entry_activity_if_needed(activity)
204 if activity['parent_id']
209 if activity['parent_id']
205
210
206 parent_activity = TimeEntryActivity.find(activity['parent_id'])
211 parent_activity = TimeEntryActivity.find(activity['parent_id'])
207 activity['name'] = parent_activity.name
212 activity['name'] = parent_activity.name
208 activity['position'] = parent_activity.position
213 activity['position'] = parent_activity.position
209
214
210 if Enumeration.overridding_change?(activity, parent_activity)
215 if Enumeration.overridding_change?(activity, parent_activity)
211 project_activity = self.time_entry_activities.create(activity)
216 project_activity = self.time_entry_activities.create(activity)
212
217
213 if project_activity.new_record?
218 if project_activity.new_record?
214 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
219 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
215 else
220 else
216 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
221 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
217 end
222 end
218 end
223 end
219 end
224 end
220 end
225 end
221
226
222 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
227 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
223 #
228 #
224 # Examples:
229 # Examples:
225 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
230 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
226 # project.project_condition(false) => "projects.id = 1"
231 # project.project_condition(false) => "projects.id = 1"
227 def project_condition(with_subprojects)
232 def project_condition(with_subprojects)
228 cond = "#{Project.table_name}.id = #{id}"
233 cond = "#{Project.table_name}.id = #{id}"
229 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
234 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
230 cond
235 cond
231 end
236 end
232
237
233 def self.find(*args)
238 def self.find(*args)
234 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
239 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
235 project = find_by_identifier(*args)
240 project = find_by_identifier(*args)
236 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
241 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
237 project
242 project
238 else
243 else
239 super
244 super
240 end
245 end
241 end
246 end
242
247
243 def to_param
248 def to_param
244 # id is used for projects with a numeric identifier (compatibility)
249 # id is used for projects with a numeric identifier (compatibility)
245 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
250 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
246 end
251 end
247
252
248 def active?
253 def active?
249 self.status == STATUS_ACTIVE
254 self.status == STATUS_ACTIVE
250 end
255 end
251
256
252 def archived?
257 def archived?
253 self.status == STATUS_ARCHIVED
258 self.status == STATUS_ARCHIVED
254 end
259 end
255
260
256 # Archives the project and its descendants
261 # Archives the project and its descendants
257 def archive
262 def archive
258 # Check that there is no issue of a non descendant project that is assigned
263 # Check that there is no issue of a non descendant project that is assigned
259 # to one of the project or descendant versions
264 # to one of the project or descendant versions
260 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
265 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
261 if v_ids.any? && Issue.find(:first, :include => :project,
266 if v_ids.any? && Issue.find(:first, :include => :project,
262 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
267 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
263 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
268 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
264 return false
269 return false
265 end
270 end
266 Project.transaction do
271 Project.transaction do
267 archive!
272 archive!
268 end
273 end
269 true
274 true
270 end
275 end
271
276
272 # Unarchives the project
277 # Unarchives the project
273 # All its ancestors must be active
278 # All its ancestors must be active
274 def unarchive
279 def unarchive
275 return false if ancestors.detect {|a| !a.active?}
280 return false if ancestors.detect {|a| !a.active?}
276 update_attribute :status, STATUS_ACTIVE
281 update_attribute :status, STATUS_ACTIVE
277 end
282 end
278
283
279 # Returns an array of projects the project can be moved to
284 # Returns an array of projects the project can be moved to
280 # by the current user
285 # by the current user
281 def allowed_parents
286 def allowed_parents
282 return @allowed_parents if @allowed_parents
287 return @allowed_parents if @allowed_parents
283 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
288 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
284 @allowed_parents = @allowed_parents - self_and_descendants
289 @allowed_parents = @allowed_parents - self_and_descendants
285 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
290 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
286 @allowed_parents << nil
291 @allowed_parents << nil
287 end
292 end
288 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
293 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
289 @allowed_parents << parent
294 @allowed_parents << parent
290 end
295 end
291 @allowed_parents
296 @allowed_parents
292 end
297 end
293
298
294 # Sets the parent of the project with authorization check
299 # Sets the parent of the project with authorization check
295 def set_allowed_parent!(p)
300 def set_allowed_parent!(p)
296 unless p.nil? || p.is_a?(Project)
301 unless p.nil? || p.is_a?(Project)
297 if p.to_s.blank?
302 if p.to_s.blank?
298 p = nil
303 p = nil
299 else
304 else
300 p = Project.find_by_id(p)
305 p = Project.find_by_id(p)
301 return false unless p
306 return false unless p
302 end
307 end
303 end
308 end
304 if p.nil?
309 if p.nil?
305 if !new_record? && allowed_parents.empty?
310 if !new_record? && allowed_parents.empty?
306 return false
311 return false
307 end
312 end
308 elsif !allowed_parents.include?(p)
313 elsif !allowed_parents.include?(p)
309 return false
314 return false
310 end
315 end
311 set_parent!(p)
316 set_parent!(p)
312 end
317 end
313
318
314 # Sets the parent of the project
319 # Sets the parent of the project
315 # Argument can be either a Project, a String, a Fixnum or nil
320 # Argument can be either a Project, a String, a Fixnum or nil
316 def set_parent!(p)
321 def set_parent!(p)
317 unless p.nil? || p.is_a?(Project)
322 unless p.nil? || p.is_a?(Project)
318 if p.to_s.blank?
323 if p.to_s.blank?
319 p = nil
324 p = nil
320 else
325 else
321 p = Project.find_by_id(p)
326 p = Project.find_by_id(p)
322 return false unless p
327 return false unless p
323 end
328 end
324 end
329 end
325 if p == parent && !p.nil?
330 if p == parent && !p.nil?
326 # Nothing to do
331 # Nothing to do
327 true
332 true
328 elsif p.nil? || (p.active? && move_possible?(p))
333 elsif p.nil? || (p.active? && move_possible?(p))
329 # Insert the project so that target's children or root projects stay alphabetically sorted
334 # Insert the project so that target's children or root projects stay alphabetically sorted
330 sibs = (p.nil? ? self.class.roots : p.children)
335 sibs = (p.nil? ? self.class.roots : p.children)
331 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
336 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
332 if to_be_inserted_before
337 if to_be_inserted_before
333 move_to_left_of(to_be_inserted_before)
338 move_to_left_of(to_be_inserted_before)
334 elsif p.nil?
339 elsif p.nil?
335 if sibs.empty?
340 if sibs.empty?
336 # move_to_root adds the project in first (ie. left) position
341 # move_to_root adds the project in first (ie. left) position
337 move_to_root
342 move_to_root
338 else
343 else
339 move_to_right_of(sibs.last) unless self == sibs.last
344 move_to_right_of(sibs.last) unless self == sibs.last
340 end
345 end
341 else
346 else
342 # move_to_child_of adds the project in last (ie.right) position
347 # move_to_child_of adds the project in last (ie.right) position
343 move_to_child_of(p)
348 move_to_child_of(p)
344 end
349 end
345 Issue.update_versions_from_hierarchy_change(self)
350 Issue.update_versions_from_hierarchy_change(self)
346 true
351 true
347 else
352 else
348 # Can not move to the given target
353 # Can not move to the given target
349 false
354 false
350 end
355 end
351 end
356 end
352
357
353 # Returns an array of the trackers used by the project and its active sub projects
358 # Returns an array of the trackers used by the project and its active sub projects
354 def rolled_up_trackers
359 def rolled_up_trackers
355 @rolled_up_trackers ||=
360 @rolled_up_trackers ||=
356 Tracker.find(:all, :joins => :projects,
361 Tracker.find(:all, :joins => :projects,
357 :select => "DISTINCT #{Tracker.table_name}.*",
362 :select => "DISTINCT #{Tracker.table_name}.*",
358 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
363 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
359 :order => "#{Tracker.table_name}.position")
364 :order => "#{Tracker.table_name}.position")
360 end
365 end
361
366
362 # Closes open and locked project versions that are completed
367 # Closes open and locked project versions that are completed
363 def close_completed_versions
368 def close_completed_versions
364 Version.transaction do
369 Version.transaction do
365 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
370 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
366 if version.completed?
371 if version.completed?
367 version.update_attribute(:status, 'closed')
372 version.update_attribute(:status, 'closed')
368 end
373 end
369 end
374 end
370 end
375 end
371 end
376 end
372
377
373 # Returns a scope of the Versions on subprojects
378 # Returns a scope of the Versions on subprojects
374 def rolled_up_versions
379 def rolled_up_versions
375 @rolled_up_versions ||=
380 @rolled_up_versions ||=
376 Version.scoped(:include => :project,
381 Version.scoped(:include => :project,
377 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
382 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
378 end
383 end
379
384
380 # Returns a scope of the Versions used by the project
385 # Returns a scope of the Versions used by the project
381 def shared_versions
386 def shared_versions
382 @shared_versions ||= begin
387 @shared_versions ||= begin
383 r = root? ? self : root
388 r = root? ? self : root
384 Version.scoped(:include => :project,
389 Version.scoped(:include => :project,
385 :conditions => "#{Project.table_name}.id = #{id}" +
390 :conditions => "#{Project.table_name}.id = #{id}" +
386 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
391 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
387 " #{Version.table_name}.sharing = 'system'" +
392 " #{Version.table_name}.sharing = 'system'" +
388 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
393 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
389 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
394 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
390 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
395 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
391 "))")
396 "))")
392 end
397 end
393 end
398 end
394
399
395 # Returns a hash of project users grouped by role
400 # Returns a hash of project users grouped by role
396 def users_by_role
401 def users_by_role
397 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
402 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
398 m.roles.each do |r|
403 m.roles.each do |r|
399 h[r] ||= []
404 h[r] ||= []
400 h[r] << m.user
405 h[r] << m.user
401 end
406 end
402 h
407 h
403 end
408 end
404 end
409 end
405
410
406 # Deletes all project's members
411 # Deletes all project's members
407 def delete_all_members
412 def delete_all_members
408 me, mr = Member.table_name, MemberRole.table_name
413 me, mr = Member.table_name, MemberRole.table_name
409 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
414 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
410 Member.delete_all(['project_id = ?', id])
415 Member.delete_all(['project_id = ?', id])
411 end
416 end
412
417
413 # Users issues can be assigned to
418 # Users issues can be assigned to
414 def assignable_users
419 def assignable_users
415 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
420 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
416 end
421 end
417
422
418 # Returns the mail adresses of users that should be always notified on project events
423 # Returns the mail adresses of users that should be always notified on project events
419 def recipients
424 def recipients
420 notified_users.collect {|user| user.mail}
425 notified_users.collect {|user| user.mail}
421 end
426 end
422
427
423 # Returns the users that should be notified on project events
428 # Returns the users that should be notified on project events
424 def notified_users
429 def notified_users
425 # TODO: User part should be extracted to User#notify_about?
430 # TODO: User part should be extracted to User#notify_about?
426 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
431 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
427 end
432 end
428
433
429 # Returns an array of all custom fields enabled for project issues
434 # Returns an array of all custom fields enabled for project issues
430 # (explictly associated custom fields and custom fields enabled for all projects)
435 # (explictly associated custom fields and custom fields enabled for all projects)
431 def all_issue_custom_fields
436 def all_issue_custom_fields
432 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
437 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
433 end
438 end
434
439
435 # Returns an array of all custom fields enabled for project time entries
440 # Returns an array of all custom fields enabled for project time entries
436 # (explictly associated custom fields and custom fields enabled for all projects)
441 # (explictly associated custom fields and custom fields enabled for all projects)
437 def all_time_entry_custom_fields
442 def all_time_entry_custom_fields
438 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
443 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
439 end
444 end
440
445
441 def project
446 def project
442 self
447 self
443 end
448 end
444
449
445 def <=>(project)
450 def <=>(project)
446 name.downcase <=> project.name.downcase
451 name.downcase <=> project.name.downcase
447 end
452 end
448
453
449 def to_s
454 def to_s
450 name
455 name
451 end
456 end
452
457
453 # Returns a short description of the projects (first lines)
458 # Returns a short description of the projects (first lines)
454 def short_description(length = 255)
459 def short_description(length = 255)
455 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
460 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
456 end
461 end
457
462
458 def css_classes
463 def css_classes
459 s = 'project'
464 s = 'project'
460 s << ' root' if root?
465 s << ' root' if root?
461 s << ' child' if child?
466 s << ' child' if child?
462 s << (leaf? ? ' leaf' : ' parent')
467 s << (leaf? ? ' leaf' : ' parent')
463 s
468 s
464 end
469 end
465
470
466 # The earliest start date of a project, based on it's issues and versions
471 # The earliest start date of a project, based on it's issues and versions
467 def start_date
472 def start_date
468 [
473 [
469 issues.minimum('start_date'),
474 issues.minimum('start_date'),
470 shared_versions.collect(&:effective_date),
475 shared_versions.collect(&:effective_date),
471 shared_versions.collect(&:start_date)
476 shared_versions.collect(&:start_date)
472 ].flatten.compact.min
477 ].flatten.compact.min
473 end
478 end
474
479
475 # The latest due date of an issue or version
480 # The latest due date of an issue or version
476 def due_date
481 def due_date
477 [
482 [
478 issues.maximum('due_date'),
483 issues.maximum('due_date'),
479 shared_versions.collect(&:effective_date),
484 shared_versions.collect(&:effective_date),
480 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
485 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
481 ].flatten.compact.max
486 ].flatten.compact.max
482 end
487 end
483
488
484 def overdue?
489 def overdue?
485 active? && !due_date.nil? && (due_date < Date.today)
490 active? && !due_date.nil? && (due_date < Date.today)
486 end
491 end
487
492
488 # Returns the percent completed for this project, based on the
493 # Returns the percent completed for this project, based on the
489 # progress on it's versions.
494 # progress on it's versions.
490 def completed_percent(options={:include_subprojects => false})
495 def completed_percent(options={:include_subprojects => false})
491 if options.delete(:include_subprojects)
496 if options.delete(:include_subprojects)
492 total = self_and_descendants.collect(&:completed_percent).sum
497 total = self_and_descendants.collect(&:completed_percent).sum
493
498
494 total / self_and_descendants.count
499 total / self_and_descendants.count
495 else
500 else
496 if versions.count > 0
501 if versions.count > 0
497 total = versions.collect(&:completed_pourcent).sum
502 total = versions.collect(&:completed_pourcent).sum
498
503
499 total / versions.count
504 total / versions.count
500 else
505 else
501 100
506 100
502 end
507 end
503 end
508 end
504 end
509 end
505
510
506 # Return true if this project is allowed to do the specified action.
511 # Return true if this project is allowed to do the specified action.
507 # action can be:
512 # action can be:
508 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
513 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
509 # * a permission Symbol (eg. :edit_project)
514 # * a permission Symbol (eg. :edit_project)
510 def allows_to?(action)
515 def allows_to?(action)
511 if action.is_a? Hash
516 if action.is_a? Hash
512 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
517 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
513 else
518 else
514 allowed_permissions.include? action
519 allowed_permissions.include? action
515 end
520 end
516 end
521 end
517
522
518 def module_enabled?(module_name)
523 def module_enabled?(module_name)
519 module_name = module_name.to_s
524 module_name = module_name.to_s
520 enabled_modules.detect {|m| m.name == module_name}
525 enabled_modules.detect {|m| m.name == module_name}
521 end
526 end
522
527
523 def enabled_module_names=(module_names)
528 def enabled_module_names=(module_names)
524 if module_names && module_names.is_a?(Array)
529 if module_names && module_names.is_a?(Array)
525 module_names = module_names.collect(&:to_s).reject(&:blank?)
530 module_names = module_names.collect(&:to_s).reject(&:blank?)
526 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
531 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
527 else
532 else
528 enabled_modules.clear
533 enabled_modules.clear
529 end
534 end
530 end
535 end
531
536
532 # Returns an array of the enabled modules names
537 # Returns an array of the enabled modules names
533 def enabled_module_names
538 def enabled_module_names
534 enabled_modules.collect(&:name)
539 enabled_modules.collect(&:name)
535 end
540 end
536
541
537 safe_attributes 'name',
542 safe_attributes 'name',
538 'description',
543 'description',
539 'homepage',
544 'homepage',
540 'is_public',
545 'is_public',
541 'identifier',
546 'identifier',
542 'custom_field_values',
547 'custom_field_values',
543 'custom_fields',
548 'custom_fields',
544 'tracker_ids',
549 'tracker_ids',
545 'issue_custom_field_ids'
550 'issue_custom_field_ids'
546
551
547 safe_attributes 'enabled_module_names',
552 safe_attributes 'enabled_module_names',
548 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
553 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
549
554
550 # Returns an array of projects that are in this project's hierarchy
555 # Returns an array of projects that are in this project's hierarchy
551 #
556 #
552 # Example: parents, children, siblings
557 # Example: parents, children, siblings
553 def hierarchy
558 def hierarchy
554 parents = project.self_and_ancestors || []
559 parents = project.self_and_ancestors || []
555 descendants = project.descendants || []
560 descendants = project.descendants || []
556 project_hierarchy = parents | descendants # Set union
561 project_hierarchy = parents | descendants # Set union
557 end
562 end
558
563
559 # Returns an auto-generated project identifier based on the last identifier used
564 # Returns an auto-generated project identifier based on the last identifier used
560 def self.next_identifier
565 def self.next_identifier
561 p = Project.find(:first, :order => 'created_on DESC')
566 p = Project.find(:first, :order => 'created_on DESC')
562 p.nil? ? nil : p.identifier.to_s.succ
567 p.nil? ? nil : p.identifier.to_s.succ
563 end
568 end
564
569
565 # Copies and saves the Project instance based on the +project+.
570 # Copies and saves the Project instance based on the +project+.
566 # Duplicates the source project's:
571 # Duplicates the source project's:
567 # * Wiki
572 # * Wiki
568 # * Versions
573 # * Versions
569 # * Categories
574 # * Categories
570 # * Issues
575 # * Issues
571 # * Members
576 # * Members
572 # * Queries
577 # * Queries
573 #
578 #
574 # Accepts an +options+ argument to specify what to copy
579 # Accepts an +options+ argument to specify what to copy
575 #
580 #
576 # Examples:
581 # Examples:
577 # project.copy(1) # => copies everything
582 # project.copy(1) # => copies everything
578 # project.copy(1, :only => 'members') # => copies members only
583 # project.copy(1, :only => 'members') # => copies members only
579 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
584 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
580 def copy(project, options={})
585 def copy(project, options={})
581 project = project.is_a?(Project) ? project : Project.find(project)
586 project = project.is_a?(Project) ? project : Project.find(project)
582
587
583 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
588 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
584 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
589 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
585
590
586 Project.transaction do
591 Project.transaction do
587 if save
592 if save
588 reload
593 reload
589 to_be_copied.each do |name|
594 to_be_copied.each do |name|
590 send "copy_#{name}", project
595 send "copy_#{name}", project
591 end
596 end
592 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
597 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
593 save
598 save
594 end
599 end
595 end
600 end
596 end
601 end
597
602
598
603
599 # Copies +project+ and returns the new instance. This will not save
604 # Copies +project+ and returns the new instance. This will not save
600 # the copy
605 # the copy
601 def self.copy_from(project)
606 def self.copy_from(project)
602 begin
607 begin
603 project = project.is_a?(Project) ? project : Project.find(project)
608 project = project.is_a?(Project) ? project : Project.find(project)
604 if project
609 if project
605 # clear unique attributes
610 # clear unique attributes
606 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
611 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
607 copy = Project.new(attributes)
612 copy = Project.new(attributes)
608 copy.enabled_modules = project.enabled_modules
613 copy.enabled_modules = project.enabled_modules
609 copy.trackers = project.trackers
614 copy.trackers = project.trackers
610 copy.custom_values = project.custom_values.collect {|v| v.clone}
615 copy.custom_values = project.custom_values.collect {|v| v.clone}
611 copy.issue_custom_fields = project.issue_custom_fields
616 copy.issue_custom_fields = project.issue_custom_fields
612 return copy
617 return copy
613 else
618 else
614 return nil
619 return nil
615 end
620 end
616 rescue ActiveRecord::RecordNotFound
621 rescue ActiveRecord::RecordNotFound
617 return nil
622 return nil
618 end
623 end
619 end
624 end
620
625
621 # Yields the given block for each project with its level in the tree
626 # Yields the given block for each project with its level in the tree
622 def self.project_tree(projects, &block)
627 def self.project_tree(projects, &block)
623 ancestors = []
628 ancestors = []
624 projects.sort_by(&:lft).each do |project|
629 projects.sort_by(&:lft).each do |project|
625 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
630 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
626 ancestors.pop
631 ancestors.pop
627 end
632 end
628 yield project, ancestors.size
633 yield project, ancestors.size
629 ancestors << project
634 ancestors << project
630 end
635 end
631 end
636 end
632
637
633 private
638 private
634
639
635 # Copies wiki from +project+
640 # Copies wiki from +project+
636 def copy_wiki(project)
641 def copy_wiki(project)
637 # Check that the source project has a wiki first
642 # Check that the source project has a wiki first
638 unless project.wiki.nil?
643 unless project.wiki.nil?
639 self.wiki ||= Wiki.new
644 self.wiki ||= Wiki.new
640 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
645 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
641 wiki_pages_map = {}
646 wiki_pages_map = {}
642 project.wiki.pages.each do |page|
647 project.wiki.pages.each do |page|
643 # Skip pages without content
648 # Skip pages without content
644 next if page.content.nil?
649 next if page.content.nil?
645 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
650 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
646 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
651 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
647 new_wiki_page.content = new_wiki_content
652 new_wiki_page.content = new_wiki_content
648 wiki.pages << new_wiki_page
653 wiki.pages << new_wiki_page
649 wiki_pages_map[page.id] = new_wiki_page
654 wiki_pages_map[page.id] = new_wiki_page
650 end
655 end
651 wiki.save
656 wiki.save
652 # Reproduce page hierarchy
657 # Reproduce page hierarchy
653 project.wiki.pages.each do |page|
658 project.wiki.pages.each do |page|
654 if page.parent_id && wiki_pages_map[page.id]
659 if page.parent_id && wiki_pages_map[page.id]
655 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
660 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
656 wiki_pages_map[page.id].save
661 wiki_pages_map[page.id].save
657 end
662 end
658 end
663 end
659 end
664 end
660 end
665 end
661
666
662 # Copies versions from +project+
667 # Copies versions from +project+
663 def copy_versions(project)
668 def copy_versions(project)
664 project.versions.each do |version|
669 project.versions.each do |version|
665 new_version = Version.new
670 new_version = Version.new
666 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
671 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
667 self.versions << new_version
672 self.versions << new_version
668 end
673 end
669 end
674 end
670
675
671 # Copies issue categories from +project+
676 # Copies issue categories from +project+
672 def copy_issue_categories(project)
677 def copy_issue_categories(project)
673 project.issue_categories.each do |issue_category|
678 project.issue_categories.each do |issue_category|
674 new_issue_category = IssueCategory.new
679 new_issue_category = IssueCategory.new
675 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
680 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
676 self.issue_categories << new_issue_category
681 self.issue_categories << new_issue_category
677 end
682 end
678 end
683 end
679
684
680 # Copies issues from +project+
685 # Copies issues from +project+
681 def copy_issues(project)
686 def copy_issues(project)
682 # Stores the source issue id as a key and the copied issues as the
687 # Stores the source issue id as a key and the copied issues as the
683 # value. Used to map the two togeather for issue relations.
688 # value. Used to map the two togeather for issue relations.
684 issues_map = {}
689 issues_map = {}
685
690
686 # Get issues sorted by root_id, lft so that parent issues
691 # Get issues sorted by root_id, lft so that parent issues
687 # get copied before their children
692 # get copied before their children
688 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
693 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
689 new_issue = Issue.new
694 new_issue = Issue.new
690 new_issue.copy_from(issue)
695 new_issue.copy_from(issue)
691 new_issue.project = self
696 new_issue.project = self
692 # Reassign fixed_versions by name, since names are unique per
697 # Reassign fixed_versions by name, since names are unique per
693 # project and the versions for self are not yet saved
698 # project and the versions for self are not yet saved
694 if issue.fixed_version
699 if issue.fixed_version
695 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
700 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
696 end
701 end
697 # Reassign the category by name, since names are unique per
702 # Reassign the category by name, since names are unique per
698 # project and the categories for self are not yet saved
703 # project and the categories for self are not yet saved
699 if issue.category
704 if issue.category
700 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
705 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
701 end
706 end
702 # Parent issue
707 # Parent issue
703 if issue.parent_id
708 if issue.parent_id
704 if copied_parent = issues_map[issue.parent_id]
709 if copied_parent = issues_map[issue.parent_id]
705 new_issue.parent_issue_id = copied_parent.id
710 new_issue.parent_issue_id = copied_parent.id
706 end
711 end
707 end
712 end
708
713
709 self.issues << new_issue
714 self.issues << new_issue
710 if new_issue.new_record?
715 if new_issue.new_record?
711 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
716 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
712 else
717 else
713 issues_map[issue.id] = new_issue unless new_issue.new_record?
718 issues_map[issue.id] = new_issue unless new_issue.new_record?
714 end
719 end
715 end
720 end
716
721
717 # Relations after in case issues related each other
722 # Relations after in case issues related each other
718 project.issues.each do |issue|
723 project.issues.each do |issue|
719 new_issue = issues_map[issue.id]
724 new_issue = issues_map[issue.id]
720 unless new_issue
725 unless new_issue
721 # Issue was not copied
726 # Issue was not copied
722 next
727 next
723 end
728 end
724
729
725 # Relations
730 # Relations
726 issue.relations_from.each do |source_relation|
731 issue.relations_from.each do |source_relation|
727 new_issue_relation = IssueRelation.new
732 new_issue_relation = IssueRelation.new
728 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
733 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
729 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
734 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
730 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
735 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
731 new_issue_relation.issue_to = source_relation.issue_to
736 new_issue_relation.issue_to = source_relation.issue_to
732 end
737 end
733 new_issue.relations_from << new_issue_relation
738 new_issue.relations_from << new_issue_relation
734 end
739 end
735
740
736 issue.relations_to.each do |source_relation|
741 issue.relations_to.each do |source_relation|
737 new_issue_relation = IssueRelation.new
742 new_issue_relation = IssueRelation.new
738 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
743 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
739 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
744 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
740 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
745 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
741 new_issue_relation.issue_from = source_relation.issue_from
746 new_issue_relation.issue_from = source_relation.issue_from
742 end
747 end
743 new_issue.relations_to << new_issue_relation
748 new_issue.relations_to << new_issue_relation
744 end
749 end
745 end
750 end
746 end
751 end
747
752
748 # Copies members from +project+
753 # Copies members from +project+
749 def copy_members(project)
754 def copy_members(project)
750 # Copy users first, then groups to handle members with inherited and given roles
755 # Copy users first, then groups to handle members with inherited and given roles
751 members_to_copy = []
756 members_to_copy = []
752 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
757 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
753 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
758 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
754
759
755 members_to_copy.each do |member|
760 members_to_copy.each do |member|
756 new_member = Member.new
761 new_member = Member.new
757 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
762 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
758 # only copy non inherited roles
763 # only copy non inherited roles
759 # inherited roles will be added when copying the group membership
764 # inherited roles will be added when copying the group membership
760 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
765 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
761 next if role_ids.empty?
766 next if role_ids.empty?
762 new_member.role_ids = role_ids
767 new_member.role_ids = role_ids
763 new_member.project = self
768 new_member.project = self
764 self.members << new_member
769 self.members << new_member
765 end
770 end
766 end
771 end
767
772
768 # Copies queries from +project+
773 # Copies queries from +project+
769 def copy_queries(project)
774 def copy_queries(project)
770 project.queries.each do |query|
775 project.queries.each do |query|
771 new_query = Query.new
776 new_query = Query.new
772 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
777 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
773 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
778 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
774 new_query.project = self
779 new_query.project = self
775 self.queries << new_query
780 self.queries << new_query
776 end
781 end
777 end
782 end
778
783
779 # Copies boards from +project+
784 # Copies boards from +project+
780 def copy_boards(project)
785 def copy_boards(project)
781 project.boards.each do |board|
786 project.boards.each do |board|
782 new_board = Board.new
787 new_board = Board.new
783 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
788 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
784 new_board.project = self
789 new_board.project = self
785 self.boards << new_board
790 self.boards << new_board
786 end
791 end
787 end
792 end
788
793
789 def allowed_permissions
794 def allowed_permissions
790 @allowed_permissions ||= begin
795 @allowed_permissions ||= begin
791 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
796 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
792 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
797 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
793 end
798 end
794 end
799 end
795
800
796 def allowed_actions
801 def allowed_actions
797 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
802 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
798 end
803 end
799
804
800 # Returns all the active Systemwide and project specific activities
805 # Returns all the active Systemwide and project specific activities
801 def active_activities
806 def active_activities
802 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
807 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
803
808
804 if overridden_activity_ids.empty?
809 if overridden_activity_ids.empty?
805 return TimeEntryActivity.shared.active
810 return TimeEntryActivity.shared.active
806 else
811 else
807 return system_activities_and_project_overrides
812 return system_activities_and_project_overrides
808 end
813 end
809 end
814 end
810
815
811 # Returns all the Systemwide and project specific activities
816 # Returns all the Systemwide and project specific activities
812 # (inactive and active)
817 # (inactive and active)
813 def all_activities
818 def all_activities
814 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
819 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
815
820
816 if overridden_activity_ids.empty?
821 if overridden_activity_ids.empty?
817 return TimeEntryActivity.shared
822 return TimeEntryActivity.shared
818 else
823 else
819 return system_activities_and_project_overrides(true)
824 return system_activities_and_project_overrides(true)
820 end
825 end
821 end
826 end
822
827
823 # Returns the systemwide active activities merged with the project specific overrides
828 # Returns the systemwide active activities merged with the project specific overrides
824 def system_activities_and_project_overrides(include_inactive=false)
829 def system_activities_and_project_overrides(include_inactive=false)
825 if include_inactive
830 if include_inactive
826 return TimeEntryActivity.shared.
831 return TimeEntryActivity.shared.
827 find(:all,
832 find(:all,
828 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
833 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
829 self.time_entry_activities
834 self.time_entry_activities
830 else
835 else
831 return TimeEntryActivity.shared.active.
836 return TimeEntryActivity.shared.active.
832 find(:all,
837 find(:all,
833 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
838 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
834 self.time_entry_activities.active
839 self.time_entry_activities.active
835 end
840 end
836 end
841 end
837
842
838 # Archives subprojects recursively
843 # Archives subprojects recursively
839 def archive!
844 def archive!
840 children.each do |subproject|
845 children.each do |subproject|
841 subproject.send :archive!
846 subproject.send :archive!
842 end
847 end
843 update_attribute :status, STATUS_ARCHIVED
848 update_attribute :status, STATUS_ARCHIVED
844 end
849 end
845 end
850 end
@@ -1,110 +1,110
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class TimeEntry < ActiveRecord::Base
18 class TimeEntry < ActiveRecord::Base
19 # could have used polymorphic association
19 # could have used polymorphic association
20 # project association here allows easy loading of time entries at project level with one database trip
20 # project association here allows easy loading of time entries at project level with one database trip
21 belongs_to :project
21 belongs_to :project
22 belongs_to :issue
22 belongs_to :issue
23 belongs_to :user
23 belongs_to :user
24 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
24 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
25
25
26 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
26 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
27
27
28 acts_as_customizable
28 acts_as_customizable
29 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
29 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
30 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
30 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
31 :author => :user,
31 :author => :user,
32 :description => :comments
32 :description => :comments
33
33
34 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
34 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
35 :author_key => :user_id,
35 :author_key => :user_id,
36 :find_options => {:include => :project}
36 :find_options => {:include => :project}
37
37
38 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
38 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
39 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
39 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
40 validates_length_of :comments, :maximum => 255, :allow_nil => true
40 validates_length_of :comments, :maximum => 255, :allow_nil => true
41
41
42 named_scope :visible, lambda {|*args| {
42 named_scope :visible, lambda {|*args| {
43 :include => :project,
43 :include => :project,
44 :conditions => Project.allowed_to_condition(args.first || User.current, :view_time_entries)
44 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
45 }}
45 }}
46
46
47 def after_initialize
47 def after_initialize
48 if new_record? && self.activity.nil?
48 if new_record? && self.activity.nil?
49 if default_activity = TimeEntryActivity.default
49 if default_activity = TimeEntryActivity.default
50 self.activity_id = default_activity.id
50 self.activity_id = default_activity.id
51 end
51 end
52 self.hours = nil if hours == 0
52 self.hours = nil if hours == 0
53 end
53 end
54 end
54 end
55
55
56 def before_validation
56 def before_validation
57 self.project = issue.project if issue && project.nil?
57 self.project = issue.project if issue && project.nil?
58 end
58 end
59
59
60 def validate
60 def validate
61 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
61 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
62 errors.add :project_id, :invalid if project.nil?
62 errors.add :project_id, :invalid if project.nil?
63 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
63 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
64 end
64 end
65
65
66 def hours=(h)
66 def hours=(h)
67 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
67 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
68 end
68 end
69
69
70 # tyear, tmonth, tweek assigned where setting spent_on attributes
70 # tyear, tmonth, tweek assigned where setting spent_on attributes
71 # these attributes make time aggregations easier
71 # these attributes make time aggregations easier
72 def spent_on=(date)
72 def spent_on=(date)
73 super
73 super
74 if spent_on.is_a?(Time)
74 if spent_on.is_a?(Time)
75 self.spent_on = spent_on.to_date
75 self.spent_on = spent_on.to_date
76 end
76 end
77 self.tyear = spent_on ? spent_on.year : nil
77 self.tyear = spent_on ? spent_on.year : nil
78 self.tmonth = spent_on ? spent_on.month : nil
78 self.tmonth = spent_on ? spent_on.month : nil
79 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
79 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
80 end
80 end
81
81
82 # Returns true if the time entry can be edited by usr, otherwise false
82 # Returns true if the time entry can be edited by usr, otherwise false
83 def editable_by?(usr)
83 def editable_by?(usr)
84 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
84 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
85 end
85 end
86
86
87 # TODO: remove this method in 1.3.0
87 # TODO: remove this method in 1.3.0
88 def self.visible_by(usr)
88 def self.visible_by(usr)
89 ActiveSupport::Deprecation.warn "TimeEntry.visible_by is deprecated and will be removed in Redmine 1.3.0. Use the visible scope instead."
89 ActiveSupport::Deprecation.warn "TimeEntry.visible_by is deprecated and will be removed in Redmine 1.3.0. Use the visible scope instead."
90 with_scope(:find => { :conditions => Project.allowed_to_condition(usr, :view_time_entries) }) do
90 with_scope(:find => { :conditions => Project.allowed_to_condition(usr, :view_time_entries) }) do
91 yield
91 yield
92 end
92 end
93 end
93 end
94
94
95 def self.earilest_date_for_project(project=nil)
95 def self.earilest_date_for_project(project=nil)
96 finder_conditions = ARCondition.new(Project.allowed_to_condition(User.current, :view_time_entries))
96 finder_conditions = ARCondition.new(Project.allowed_to_condition(User.current, :view_time_entries))
97 if project
97 if project
98 finder_conditions << ["project_id IN (?)", project.hierarchy.collect(&:id)]
98 finder_conditions << ["project_id IN (?)", project.hierarchy.collect(&:id)]
99 end
99 end
100 TimeEntry.minimum(:spent_on, :include => :project, :conditions => finder_conditions.conditions)
100 TimeEntry.minimum(:spent_on, :include => :project, :conditions => finder_conditions.conditions)
101 end
101 end
102
102
103 def self.latest_date_for_project(project=nil)
103 def self.latest_date_for_project(project=nil)
104 finder_conditions = ARCondition.new(Project.allowed_to_condition(User.current, :view_time_entries))
104 finder_conditions = ARCondition.new(Project.allowed_to_condition(User.current, :view_time_entries))
105 if project
105 if project
106 finder_conditions << ["project_id IN (?)", project.hierarchy.collect(&:id)]
106 finder_conditions << ["project_id IN (?)", project.hierarchy.collect(&:id)]
107 end
107 end
108 TimeEntry.maximum(:spent_on, :include => :project, :conditions => finder_conditions.conditions)
108 TimeEntry.maximum(:spent_on, :include => :project, :conditions => finder_conditions.conditions)
109 end
109 end
110 end
110 end
@@ -1,897 +1,913
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class IssueTest < ActiveSupport::TestCase
20 class IssueTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :members, :member_roles, :roles,
21 fixtures :projects, :users, :members, :member_roles, :roles,
22 :trackers, :projects_trackers,
22 :trackers, :projects_trackers,
23 :enabled_modules,
23 :enabled_modules,
24 :versions,
24 :versions,
25 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 :issue_statuses, :issue_categories, :issue_relations, :workflows,
26 :enumerations,
26 :enumerations,
27 :issues,
27 :issues,
28 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
29 :time_entries
29 :time_entries
30
30
31 def test_create
31 def test_create
32 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
32 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
33 assert issue.save
33 assert issue.save
34 issue.reload
34 issue.reload
35 assert_equal 1.5, issue.estimated_hours
35 assert_equal 1.5, issue.estimated_hours
36 end
36 end
37
37
38 def test_create_minimal
38 def test_create_minimal
39 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
39 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
40 assert issue.save
40 assert issue.save
41 assert issue.description.nil?
41 assert issue.description.nil?
42 end
42 end
43
43
44 def test_create_with_required_custom_field
44 def test_create_with_required_custom_field
45 field = IssueCustomField.find_by_name('Database')
45 field = IssueCustomField.find_by_name('Database')
46 field.update_attribute(:is_required, true)
46 field.update_attribute(:is_required, true)
47
47
48 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
48 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
49 assert issue.available_custom_fields.include?(field)
49 assert issue.available_custom_fields.include?(field)
50 # No value for the custom field
50 # No value for the custom field
51 assert !issue.save
51 assert !issue.save
52 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
52 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
53 # Blank value
53 # Blank value
54 issue.custom_field_values = { field.id => '' }
54 issue.custom_field_values = { field.id => '' }
55 assert !issue.save
55 assert !issue.save
56 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
56 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
57 # Invalid value
57 # Invalid value
58 issue.custom_field_values = { field.id => 'SQLServer' }
58 issue.custom_field_values = { field.id => 'SQLServer' }
59 assert !issue.save
59 assert !issue.save
60 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
60 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
61 # Valid value
61 # Valid value
62 issue.custom_field_values = { field.id => 'PostgreSQL' }
62 issue.custom_field_values = { field.id => 'PostgreSQL' }
63 assert issue.save
63 assert issue.save
64 issue.reload
64 issue.reload
65 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
65 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
66 end
66 end
67
67
68 def test_visible_scope_for_anonymous
68 def test_visible_scope_for_anonymous
69 # Anonymous user should see issues of public projects only
69 # Anonymous user should see issues of public projects only
70 issues = Issue.visible(User.anonymous).all
70 issues = Issue.visible(User.anonymous).all
71 assert issues.any?
71 assert issues.any?
72 assert_nil issues.detect {|issue| !issue.project.is_public?}
72 assert_nil issues.detect {|issue| !issue.project.is_public?}
73 # Anonymous user should not see issues without permission
73 # Anonymous user should not see issues without permission
74 Role.anonymous.remove_permission!(:view_issues)
74 Role.anonymous.remove_permission!(:view_issues)
75 issues = Issue.visible(User.anonymous).all
75 issues = Issue.visible(User.anonymous).all
76 assert issues.empty?
76 assert issues.empty?
77 end
77 end
78
78
79 def test_visible_scope_for_user
79 def test_visible_scope_for_user
80 user = User.find(9)
80 user = User.find(9)
81 assert user.projects.empty?
81 assert user.projects.empty?
82 # Non member user should see issues of public projects only
82 # Non member user should see issues of public projects only
83 issues = Issue.visible(user).all
83 issues = Issue.visible(user).all
84 assert issues.any?
84 assert issues.any?
85 assert_nil issues.detect {|issue| !issue.project.is_public?}
85 assert_nil issues.detect {|issue| !issue.project.is_public?}
86 # Non member user should not see issues without permission
86 # Non member user should not see issues without permission
87 Role.non_member.remove_permission!(:view_issues)
87 Role.non_member.remove_permission!(:view_issues)
88 user.reload
88 user.reload
89 issues = Issue.visible(user).all
89 issues = Issue.visible(user).all
90 assert issues.empty?
90 assert issues.empty?
91 # User should see issues of projects for which he has view_issues permissions only
91 # User should see issues of projects for which he has view_issues permissions only
92 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
92 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
93 user.reload
93 user.reload
94 issues = Issue.visible(user).all
94 issues = Issue.visible(user).all
95 assert issues.any?
95 assert issues.any?
96 assert_nil issues.detect {|issue| issue.project_id != 2}
96 assert_nil issues.detect {|issue| issue.project_id != 2}
97 end
97 end
98
98
99 def test_visible_scope_for_admin
99 def test_visible_scope_for_admin
100 user = User.find(1)
100 user = User.find(1)
101 user.members.each(&:destroy)
101 user.members.each(&:destroy)
102 assert user.projects.empty?
102 assert user.projects.empty?
103 issues = Issue.visible(user).all
103 issues = Issue.visible(user).all
104 assert issues.any?
104 assert issues.any?
105 # Admin should see issues on private projects that he does not belong to
105 # Admin should see issues on private projects that he does not belong to
106 assert issues.detect {|issue| !issue.project.is_public?}
106 assert issues.detect {|issue| !issue.project.is_public?}
107 end
107 end
108
108
109 def test_visible_scope_with_project
110 project = Project.find(1)
111 issues = Issue.visible(User.find(2), :project => project).all
112 projects = issues.collect(&:project).uniq
113 assert_equal 1, projects.size
114 assert_equal project, projects.first
115 end
116
117 def test_visible_scope_with_project_and_subprojects
118 project = Project.find(1)
119 issues = Issue.visible(User.find(2), :project => project, :with_subprojects => true).all
120 projects = issues.collect(&:project).uniq
121 assert projects.size > 1
122 assert_equal [], projects.select {|p| !p.is_or_is_descendant_of?(project)}
123 end
124
109 def test_errors_full_messages_should_include_custom_fields_errors
125 def test_errors_full_messages_should_include_custom_fields_errors
110 field = IssueCustomField.find_by_name('Database')
126 field = IssueCustomField.find_by_name('Database')
111
127
112 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
128 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
113 assert issue.available_custom_fields.include?(field)
129 assert issue.available_custom_fields.include?(field)
114 # Invalid value
130 # Invalid value
115 issue.custom_field_values = { field.id => 'SQLServer' }
131 issue.custom_field_values = { field.id => 'SQLServer' }
116
132
117 assert !issue.valid?
133 assert !issue.valid?
118 assert_equal 1, issue.errors.full_messages.size
134 assert_equal 1, issue.errors.full_messages.size
119 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
135 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
120 end
136 end
121
137
122 def test_update_issue_with_required_custom_field
138 def test_update_issue_with_required_custom_field
123 field = IssueCustomField.find_by_name('Database')
139 field = IssueCustomField.find_by_name('Database')
124 field.update_attribute(:is_required, true)
140 field.update_attribute(:is_required, true)
125
141
126 issue = Issue.find(1)
142 issue = Issue.find(1)
127 assert_nil issue.custom_value_for(field)
143 assert_nil issue.custom_value_for(field)
128 assert issue.available_custom_fields.include?(field)
144 assert issue.available_custom_fields.include?(field)
129 # No change to custom values, issue can be saved
145 # No change to custom values, issue can be saved
130 assert issue.save
146 assert issue.save
131 # Blank value
147 # Blank value
132 issue.custom_field_values = { field.id => '' }
148 issue.custom_field_values = { field.id => '' }
133 assert !issue.save
149 assert !issue.save
134 # Valid value
150 # Valid value
135 issue.custom_field_values = { field.id => 'PostgreSQL' }
151 issue.custom_field_values = { field.id => 'PostgreSQL' }
136 assert issue.save
152 assert issue.save
137 issue.reload
153 issue.reload
138 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
154 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
139 end
155 end
140
156
141 def test_should_not_update_attributes_if_custom_fields_validation_fails
157 def test_should_not_update_attributes_if_custom_fields_validation_fails
142 issue = Issue.find(1)
158 issue = Issue.find(1)
143 field = IssueCustomField.find_by_name('Database')
159 field = IssueCustomField.find_by_name('Database')
144 assert issue.available_custom_fields.include?(field)
160 assert issue.available_custom_fields.include?(field)
145
161
146 issue.custom_field_values = { field.id => 'Invalid' }
162 issue.custom_field_values = { field.id => 'Invalid' }
147 issue.subject = 'Should be not be saved'
163 issue.subject = 'Should be not be saved'
148 assert !issue.save
164 assert !issue.save
149
165
150 issue.reload
166 issue.reload
151 assert_equal "Can't print recipes", issue.subject
167 assert_equal "Can't print recipes", issue.subject
152 end
168 end
153
169
154 def test_should_not_recreate_custom_values_objects_on_update
170 def test_should_not_recreate_custom_values_objects_on_update
155 field = IssueCustomField.find_by_name('Database')
171 field = IssueCustomField.find_by_name('Database')
156
172
157 issue = Issue.find(1)
173 issue = Issue.find(1)
158 issue.custom_field_values = { field.id => 'PostgreSQL' }
174 issue.custom_field_values = { field.id => 'PostgreSQL' }
159 assert issue.save
175 assert issue.save
160 custom_value = issue.custom_value_for(field)
176 custom_value = issue.custom_value_for(field)
161 issue.reload
177 issue.reload
162 issue.custom_field_values = { field.id => 'MySQL' }
178 issue.custom_field_values = { field.id => 'MySQL' }
163 assert issue.save
179 assert issue.save
164 issue.reload
180 issue.reload
165 assert_equal custom_value.id, issue.custom_value_for(field).id
181 assert_equal custom_value.id, issue.custom_value_for(field).id
166 end
182 end
167
183
168 def test_assigning_tracker_id_should_reload_custom_fields_values
184 def test_assigning_tracker_id_should_reload_custom_fields_values
169 issue = Issue.new(:project => Project.find(1))
185 issue = Issue.new(:project => Project.find(1))
170 assert issue.custom_field_values.empty?
186 assert issue.custom_field_values.empty?
171 issue.tracker_id = 1
187 issue.tracker_id = 1
172 assert issue.custom_field_values.any?
188 assert issue.custom_field_values.any?
173 end
189 end
174
190
175 def test_assigning_attributes_should_assign_tracker_id_first
191 def test_assigning_attributes_should_assign_tracker_id_first
176 attributes = ActiveSupport::OrderedHash.new
192 attributes = ActiveSupport::OrderedHash.new
177 attributes['custom_field_values'] = { '1' => 'MySQL' }
193 attributes['custom_field_values'] = { '1' => 'MySQL' }
178 attributes['tracker_id'] = '1'
194 attributes['tracker_id'] = '1'
179 issue = Issue.new(:project => Project.find(1))
195 issue = Issue.new(:project => Project.find(1))
180 issue.attributes = attributes
196 issue.attributes = attributes
181 assert_not_nil issue.custom_value_for(1)
197 assert_not_nil issue.custom_value_for(1)
182 assert_equal 'MySQL', issue.custom_value_for(1).value
198 assert_equal 'MySQL', issue.custom_value_for(1).value
183 end
199 end
184
200
185 def test_should_update_issue_with_disabled_tracker
201 def test_should_update_issue_with_disabled_tracker
186 p = Project.find(1)
202 p = Project.find(1)
187 issue = Issue.find(1)
203 issue = Issue.find(1)
188
204
189 p.trackers.delete(issue.tracker)
205 p.trackers.delete(issue.tracker)
190 assert !p.trackers.include?(issue.tracker)
206 assert !p.trackers.include?(issue.tracker)
191
207
192 issue.reload
208 issue.reload
193 issue.subject = 'New subject'
209 issue.subject = 'New subject'
194 assert issue.save
210 assert issue.save
195 end
211 end
196
212
197 def test_should_not_set_a_disabled_tracker
213 def test_should_not_set_a_disabled_tracker
198 p = Project.find(1)
214 p = Project.find(1)
199 p.trackers.delete(Tracker.find(2))
215 p.trackers.delete(Tracker.find(2))
200
216
201 issue = Issue.find(1)
217 issue = Issue.find(1)
202 issue.tracker_id = 2
218 issue.tracker_id = 2
203 issue.subject = 'New subject'
219 issue.subject = 'New subject'
204 assert !issue.save
220 assert !issue.save
205 assert_not_nil issue.errors.on(:tracker_id)
221 assert_not_nil issue.errors.on(:tracker_id)
206 end
222 end
207
223
208 def test_category_based_assignment
224 def test_category_based_assignment
209 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Assignment test', :description => 'Assignment test', :category_id => 1)
225 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Assignment test', :description => 'Assignment test', :category_id => 1)
210 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
226 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
211 end
227 end
212
228
213
229
214
230
215 def test_new_statuses_allowed_to
231 def test_new_statuses_allowed_to
216 Workflow.delete_all
232 Workflow.delete_all
217
233
218 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 2, :author => false, :assignee => false)
234 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 2, :author => false, :assignee => false)
219 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 3, :author => true, :assignee => false)
235 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 3, :author => true, :assignee => false)
220 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 4, :author => false, :assignee => true)
236 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 4, :author => false, :assignee => true)
221 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 5, :author => true, :assignee => true)
237 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 5, :author => true, :assignee => true)
222 status = IssueStatus.find(1)
238 status = IssueStatus.find(1)
223 role = Role.find(1)
239 role = Role.find(1)
224 tracker = Tracker.find(1)
240 tracker = Tracker.find(1)
225 user = User.find(2)
241 user = User.find(2)
226
242
227 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1)
243 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1)
228 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
244 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
229
245
230 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user)
246 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user)
231 assert_equal [1, 2, 3], issue.new_statuses_allowed_to(user).map(&:id)
247 assert_equal [1, 2, 3], issue.new_statuses_allowed_to(user).map(&:id)
232
248
233 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :assigned_to => user)
249 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :assigned_to => user)
234 assert_equal [1, 2, 4], issue.new_statuses_allowed_to(user).map(&:id)
250 assert_equal [1, 2, 4], issue.new_statuses_allowed_to(user).map(&:id)
235
251
236 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user, :assigned_to => user)
252 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user, :assigned_to => user)
237 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
253 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
238 end
254 end
239
255
240 def test_copy
256 def test_copy
241 issue = Issue.new.copy_from(1)
257 issue = Issue.new.copy_from(1)
242 assert issue.save
258 assert issue.save
243 issue.reload
259 issue.reload
244 orig = Issue.find(1)
260 orig = Issue.find(1)
245 assert_equal orig.subject, issue.subject
261 assert_equal orig.subject, issue.subject
246 assert_equal orig.tracker, issue.tracker
262 assert_equal orig.tracker, issue.tracker
247 assert_equal "125", issue.custom_value_for(2).value
263 assert_equal "125", issue.custom_value_for(2).value
248 end
264 end
249
265
250 def test_copy_should_copy_status
266 def test_copy_should_copy_status
251 orig = Issue.find(8)
267 orig = Issue.find(8)
252 assert orig.status != IssueStatus.default
268 assert orig.status != IssueStatus.default
253
269
254 issue = Issue.new.copy_from(orig)
270 issue = Issue.new.copy_from(orig)
255 assert issue.save
271 assert issue.save
256 issue.reload
272 issue.reload
257 assert_equal orig.status, issue.status
273 assert_equal orig.status, issue.status
258 end
274 end
259
275
260 def test_should_close_duplicates
276 def test_should_close_duplicates
261 # Create 3 issues
277 # Create 3 issues
262 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
278 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
263 assert issue1.save
279 assert issue1.save
264 issue2 = issue1.clone
280 issue2 = issue1.clone
265 assert issue2.save
281 assert issue2.save
266 issue3 = issue1.clone
282 issue3 = issue1.clone
267 assert issue3.save
283 assert issue3.save
268
284
269 # 2 is a dupe of 1
285 # 2 is a dupe of 1
270 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
286 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
271 # And 3 is a dupe of 2
287 # And 3 is a dupe of 2
272 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
288 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
273 # And 3 is a dupe of 1 (circular duplicates)
289 # And 3 is a dupe of 1 (circular duplicates)
274 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
290 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
275
291
276 assert issue1.reload.duplicates.include?(issue2)
292 assert issue1.reload.duplicates.include?(issue2)
277
293
278 # Closing issue 1
294 # Closing issue 1
279 issue1.init_journal(User.find(:first), "Closing issue1")
295 issue1.init_journal(User.find(:first), "Closing issue1")
280 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
296 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
281 assert issue1.save
297 assert issue1.save
282 # 2 and 3 should be also closed
298 # 2 and 3 should be also closed
283 assert issue2.reload.closed?
299 assert issue2.reload.closed?
284 assert issue3.reload.closed?
300 assert issue3.reload.closed?
285 end
301 end
286
302
287 def test_should_not_close_duplicated_issue
303 def test_should_not_close_duplicated_issue
288 # Create 3 issues
304 # Create 3 issues
289 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
305 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
290 assert issue1.save
306 assert issue1.save
291 issue2 = issue1.clone
307 issue2 = issue1.clone
292 assert issue2.save
308 assert issue2.save
293
309
294 # 2 is a dupe of 1
310 # 2 is a dupe of 1
295 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
311 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
296 # 2 is a dup of 1 but 1 is not a duplicate of 2
312 # 2 is a dup of 1 but 1 is not a duplicate of 2
297 assert !issue2.reload.duplicates.include?(issue1)
313 assert !issue2.reload.duplicates.include?(issue1)
298
314
299 # Closing issue 2
315 # Closing issue 2
300 issue2.init_journal(User.find(:first), "Closing issue2")
316 issue2.init_journal(User.find(:first), "Closing issue2")
301 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
317 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
302 assert issue2.save
318 assert issue2.save
303 # 1 should not be also closed
319 # 1 should not be also closed
304 assert !issue1.reload.closed?
320 assert !issue1.reload.closed?
305 end
321 end
306
322
307 def test_assignable_versions
323 def test_assignable_versions
308 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
324 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
309 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
325 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
310 end
326 end
311
327
312 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
328 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
313 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
329 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
314 assert !issue.save
330 assert !issue.save
315 assert_not_nil issue.errors.on(:fixed_version_id)
331 assert_not_nil issue.errors.on(:fixed_version_id)
316 end
332 end
317
333
318 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
334 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
319 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
335 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
320 assert !issue.save
336 assert !issue.save
321 assert_not_nil issue.errors.on(:fixed_version_id)
337 assert_not_nil issue.errors.on(:fixed_version_id)
322 end
338 end
323
339
324 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
340 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
325 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
341 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
326 assert issue.save
342 assert issue.save
327 end
343 end
328
344
329 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
345 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
330 issue = Issue.find(11)
346 issue = Issue.find(11)
331 assert_equal 'closed', issue.fixed_version.status
347 assert_equal 'closed', issue.fixed_version.status
332 issue.subject = 'Subject changed'
348 issue.subject = 'Subject changed'
333 assert issue.save
349 assert issue.save
334 end
350 end
335
351
336 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
352 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
337 issue = Issue.find(11)
353 issue = Issue.find(11)
338 issue.status_id = 1
354 issue.status_id = 1
339 assert !issue.save
355 assert !issue.save
340 assert_not_nil issue.errors.on_base
356 assert_not_nil issue.errors.on_base
341 end
357 end
342
358
343 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
359 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
344 issue = Issue.find(11)
360 issue = Issue.find(11)
345 issue.status_id = 1
361 issue.status_id = 1
346 issue.fixed_version_id = 3
362 issue.fixed_version_id = 3
347 assert issue.save
363 assert issue.save
348 end
364 end
349
365
350 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
366 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
351 issue = Issue.find(12)
367 issue = Issue.find(12)
352 assert_equal 'locked', issue.fixed_version.status
368 assert_equal 'locked', issue.fixed_version.status
353 issue.status_id = 1
369 issue.status_id = 1
354 assert issue.save
370 assert issue.save
355 end
371 end
356
372
357 def test_move_to_another_project_with_same_category
373 def test_move_to_another_project_with_same_category
358 issue = Issue.find(1)
374 issue = Issue.find(1)
359 assert issue.move_to_project(Project.find(2))
375 assert issue.move_to_project(Project.find(2))
360 issue.reload
376 issue.reload
361 assert_equal 2, issue.project_id
377 assert_equal 2, issue.project_id
362 # Category changes
378 # Category changes
363 assert_equal 4, issue.category_id
379 assert_equal 4, issue.category_id
364 # Make sure time entries were move to the target project
380 # Make sure time entries were move to the target project
365 assert_equal 2, issue.time_entries.first.project_id
381 assert_equal 2, issue.time_entries.first.project_id
366 end
382 end
367
383
368 def test_move_to_another_project_without_same_category
384 def test_move_to_another_project_without_same_category
369 issue = Issue.find(2)
385 issue = Issue.find(2)
370 assert issue.move_to_project(Project.find(2))
386 assert issue.move_to_project(Project.find(2))
371 issue.reload
387 issue.reload
372 assert_equal 2, issue.project_id
388 assert_equal 2, issue.project_id
373 # Category cleared
389 # Category cleared
374 assert_nil issue.category_id
390 assert_nil issue.category_id
375 end
391 end
376
392
377 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
393 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
378 issue = Issue.find(1)
394 issue = Issue.find(1)
379 issue.update_attribute(:fixed_version_id, 1)
395 issue.update_attribute(:fixed_version_id, 1)
380 assert issue.move_to_project(Project.find(2))
396 assert issue.move_to_project(Project.find(2))
381 issue.reload
397 issue.reload
382 assert_equal 2, issue.project_id
398 assert_equal 2, issue.project_id
383 # Cleared fixed_version
399 # Cleared fixed_version
384 assert_equal nil, issue.fixed_version
400 assert_equal nil, issue.fixed_version
385 end
401 end
386
402
387 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
403 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
388 issue = Issue.find(1)
404 issue = Issue.find(1)
389 issue.update_attribute(:fixed_version_id, 4)
405 issue.update_attribute(:fixed_version_id, 4)
390 assert issue.move_to_project(Project.find(5))
406 assert issue.move_to_project(Project.find(5))
391 issue.reload
407 issue.reload
392 assert_equal 5, issue.project_id
408 assert_equal 5, issue.project_id
393 # Keep fixed_version
409 # Keep fixed_version
394 assert_equal 4, issue.fixed_version_id
410 assert_equal 4, issue.fixed_version_id
395 end
411 end
396
412
397 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
413 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
398 issue = Issue.find(1)
414 issue = Issue.find(1)
399 issue.update_attribute(:fixed_version_id, 1)
415 issue.update_attribute(:fixed_version_id, 1)
400 assert issue.move_to_project(Project.find(5))
416 assert issue.move_to_project(Project.find(5))
401 issue.reload
417 issue.reload
402 assert_equal 5, issue.project_id
418 assert_equal 5, issue.project_id
403 # Cleared fixed_version
419 # Cleared fixed_version
404 assert_equal nil, issue.fixed_version
420 assert_equal nil, issue.fixed_version
405 end
421 end
406
422
407 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
423 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
408 issue = Issue.find(1)
424 issue = Issue.find(1)
409 issue.update_attribute(:fixed_version_id, 7)
425 issue.update_attribute(:fixed_version_id, 7)
410 assert issue.move_to_project(Project.find(2))
426 assert issue.move_to_project(Project.find(2))
411 issue.reload
427 issue.reload
412 assert_equal 2, issue.project_id
428 assert_equal 2, issue.project_id
413 # Keep fixed_version
429 # Keep fixed_version
414 assert_equal 7, issue.fixed_version_id
430 assert_equal 7, issue.fixed_version_id
415 end
431 end
416
432
417 def test_move_to_another_project_with_disabled_tracker
433 def test_move_to_another_project_with_disabled_tracker
418 issue = Issue.find(1)
434 issue = Issue.find(1)
419 target = Project.find(2)
435 target = Project.find(2)
420 target.tracker_ids = [3]
436 target.tracker_ids = [3]
421 target.save
437 target.save
422 assert_equal false, issue.move_to_project(target)
438 assert_equal false, issue.move_to_project(target)
423 issue.reload
439 issue.reload
424 assert_equal 1, issue.project_id
440 assert_equal 1, issue.project_id
425 end
441 end
426
442
427 def test_copy_to_the_same_project
443 def test_copy_to_the_same_project
428 issue = Issue.find(1)
444 issue = Issue.find(1)
429 copy = nil
445 copy = nil
430 assert_difference 'Issue.count' do
446 assert_difference 'Issue.count' do
431 copy = issue.move_to_project(issue.project, nil, :copy => true)
447 copy = issue.move_to_project(issue.project, nil, :copy => true)
432 end
448 end
433 assert_kind_of Issue, copy
449 assert_kind_of Issue, copy
434 assert_equal issue.project, copy.project
450 assert_equal issue.project, copy.project
435 assert_equal "125", copy.custom_value_for(2).value
451 assert_equal "125", copy.custom_value_for(2).value
436 end
452 end
437
453
438 def test_copy_to_another_project_and_tracker
454 def test_copy_to_another_project_and_tracker
439 issue = Issue.find(1)
455 issue = Issue.find(1)
440 copy = nil
456 copy = nil
441 assert_difference 'Issue.count' do
457 assert_difference 'Issue.count' do
442 copy = issue.move_to_project(Project.find(3), Tracker.find(2), :copy => true)
458 copy = issue.move_to_project(Project.find(3), Tracker.find(2), :copy => true)
443 end
459 end
444 copy.reload
460 copy.reload
445 assert_kind_of Issue, copy
461 assert_kind_of Issue, copy
446 assert_equal Project.find(3), copy.project
462 assert_equal Project.find(3), copy.project
447 assert_equal Tracker.find(2), copy.tracker
463 assert_equal Tracker.find(2), copy.tracker
448 # Custom field #2 is not associated with target tracker
464 # Custom field #2 is not associated with target tracker
449 assert_nil copy.custom_value_for(2)
465 assert_nil copy.custom_value_for(2)
450 end
466 end
451
467
452 context "#move_to_project" do
468 context "#move_to_project" do
453 context "as a copy" do
469 context "as a copy" do
454 setup do
470 setup do
455 @issue = Issue.find(1)
471 @issue = Issue.find(1)
456 @copy = nil
472 @copy = nil
457 end
473 end
458
474
459 should "allow assigned_to changes" do
475 should "allow assigned_to changes" do
460 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
476 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
461 assert_equal 3, @copy.assigned_to_id
477 assert_equal 3, @copy.assigned_to_id
462 end
478 end
463
479
464 should "allow status changes" do
480 should "allow status changes" do
465 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
481 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
466 assert_equal 2, @copy.status_id
482 assert_equal 2, @copy.status_id
467 end
483 end
468
484
469 should "allow start date changes" do
485 should "allow start date changes" do
470 date = Date.today
486 date = Date.today
471 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
487 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
472 assert_equal date, @copy.start_date
488 assert_equal date, @copy.start_date
473 end
489 end
474
490
475 should "allow due date changes" do
491 should "allow due date changes" do
476 date = Date.today
492 date = Date.today
477 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
493 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
478
494
479 assert_equal date, @copy.due_date
495 assert_equal date, @copy.due_date
480 end
496 end
481 end
497 end
482 end
498 end
483
499
484 def test_recipients_should_not_include_users_that_cannot_view_the_issue
500 def test_recipients_should_not_include_users_that_cannot_view_the_issue
485 issue = Issue.find(12)
501 issue = Issue.find(12)
486 assert issue.recipients.include?(issue.author.mail)
502 assert issue.recipients.include?(issue.author.mail)
487 # move the issue to a private project
503 # move the issue to a private project
488 copy = issue.move_to_project(Project.find(5), Tracker.find(2), :copy => true)
504 copy = issue.move_to_project(Project.find(5), Tracker.find(2), :copy => true)
489 # author is not a member of project anymore
505 # author is not a member of project anymore
490 assert !copy.recipients.include?(copy.author.mail)
506 assert !copy.recipients.include?(copy.author.mail)
491 end
507 end
492
508
493 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
509 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
494 user = User.find(3)
510 user = User.find(3)
495 issue = Issue.find(9)
511 issue = Issue.find(9)
496 Watcher.create!(:user => user, :watchable => issue)
512 Watcher.create!(:user => user, :watchable => issue)
497 assert issue.watched_by?(user)
513 assert issue.watched_by?(user)
498 assert !issue.watcher_recipients.include?(user.mail)
514 assert !issue.watcher_recipients.include?(user.mail)
499 end
515 end
500
516
501 def test_issue_destroy
517 def test_issue_destroy
502 Issue.find(1).destroy
518 Issue.find(1).destroy
503 assert_nil Issue.find_by_id(1)
519 assert_nil Issue.find_by_id(1)
504 assert_nil TimeEntry.find_by_issue_id(1)
520 assert_nil TimeEntry.find_by_issue_id(1)
505 end
521 end
506
522
507 def test_blocked
523 def test_blocked
508 blocked_issue = Issue.find(9)
524 blocked_issue = Issue.find(9)
509 blocking_issue = Issue.find(10)
525 blocking_issue = Issue.find(10)
510
526
511 assert blocked_issue.blocked?
527 assert blocked_issue.blocked?
512 assert !blocking_issue.blocked?
528 assert !blocking_issue.blocked?
513 end
529 end
514
530
515 def test_blocked_issues_dont_allow_closed_statuses
531 def test_blocked_issues_dont_allow_closed_statuses
516 blocked_issue = Issue.find(9)
532 blocked_issue = Issue.find(9)
517
533
518 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
534 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
519 assert !allowed_statuses.empty?
535 assert !allowed_statuses.empty?
520 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
536 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
521 assert closed_statuses.empty?
537 assert closed_statuses.empty?
522 end
538 end
523
539
524 def test_unblocked_issues_allow_closed_statuses
540 def test_unblocked_issues_allow_closed_statuses
525 blocking_issue = Issue.find(10)
541 blocking_issue = Issue.find(10)
526
542
527 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
543 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
528 assert !allowed_statuses.empty?
544 assert !allowed_statuses.empty?
529 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
545 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
530 assert !closed_statuses.empty?
546 assert !closed_statuses.empty?
531 end
547 end
532
548
533 def test_rescheduling_an_issue_should_reschedule_following_issue
549 def test_rescheduling_an_issue_should_reschedule_following_issue
534 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
550 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
535 issue2 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
551 issue2 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
536 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
552 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
537 assert_equal issue1.due_date + 1, issue2.reload.start_date
553 assert_equal issue1.due_date + 1, issue2.reload.start_date
538
554
539 issue1.due_date = Date.today + 5
555 issue1.due_date = Date.today + 5
540 issue1.save!
556 issue1.save!
541 assert_equal issue1.due_date + 1, issue2.reload.start_date
557 assert_equal issue1.due_date + 1, issue2.reload.start_date
542 end
558 end
543
559
544 def test_overdue
560 def test_overdue
545 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
561 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
546 assert !Issue.new(:due_date => Date.today).overdue?
562 assert !Issue.new(:due_date => Date.today).overdue?
547 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
563 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
548 assert !Issue.new(:due_date => nil).overdue?
564 assert !Issue.new(:due_date => nil).overdue?
549 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
565 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
550 end
566 end
551
567
552 context "#behind_schedule?" do
568 context "#behind_schedule?" do
553 should "be false if the issue has no start_date" do
569 should "be false if the issue has no start_date" do
554 assert !Issue.new(:start_date => nil, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
570 assert !Issue.new(:start_date => nil, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
555 end
571 end
556
572
557 should "be false if the issue has no end_date" do
573 should "be false if the issue has no end_date" do
558 assert !Issue.new(:start_date => 1.day.from_now.to_date, :due_date => nil, :done_ratio => 0).behind_schedule?
574 assert !Issue.new(:start_date => 1.day.from_now.to_date, :due_date => nil, :done_ratio => 0).behind_schedule?
559 end
575 end
560
576
561 should "be false if the issue has more done than it's calendar time" do
577 should "be false if the issue has more done than it's calendar time" do
562 assert !Issue.new(:start_date => 50.days.ago.to_date, :due_date => 50.days.from_now.to_date, :done_ratio => 90).behind_schedule?
578 assert !Issue.new(:start_date => 50.days.ago.to_date, :due_date => 50.days.from_now.to_date, :done_ratio => 90).behind_schedule?
563 end
579 end
564
580
565 should "be true if the issue hasn't been started at all" do
581 should "be true if the issue hasn't been started at all" do
566 assert Issue.new(:start_date => 1.day.ago.to_date, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
582 assert Issue.new(:start_date => 1.day.ago.to_date, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
567 end
583 end
568
584
569 should "be true if the issue has used more calendar time than it's done ratio" do
585 should "be true if the issue has used more calendar time than it's done ratio" do
570 assert Issue.new(:start_date => 100.days.ago.to_date, :due_date => Date.today, :done_ratio => 90).behind_schedule?
586 assert Issue.new(:start_date => 100.days.ago.to_date, :due_date => Date.today, :done_ratio => 90).behind_schedule?
571 end
587 end
572 end
588 end
573
589
574 context "#assignable_users" do
590 context "#assignable_users" do
575 should "be Users" do
591 should "be Users" do
576 assert_kind_of User, Issue.find(1).assignable_users.first
592 assert_kind_of User, Issue.find(1).assignable_users.first
577 end
593 end
578
594
579 should "include the issue author" do
595 should "include the issue author" do
580 project = Project.find(1)
596 project = Project.find(1)
581 non_project_member = User.generate!
597 non_project_member = User.generate!
582 issue = Issue.generate_for_project!(project, :author => non_project_member)
598 issue = Issue.generate_for_project!(project, :author => non_project_member)
583
599
584 assert issue.assignable_users.include?(non_project_member)
600 assert issue.assignable_users.include?(non_project_member)
585 end
601 end
586
602
587 should "not show the issue author twice" do
603 should "not show the issue author twice" do
588 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
604 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
589 assert_equal 2, assignable_user_ids.length
605 assert_equal 2, assignable_user_ids.length
590
606
591 assignable_user_ids.each do |user_id|
607 assignable_user_ids.each do |user_id|
592 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length, "User #{user_id} appears more or less than once"
608 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length, "User #{user_id} appears more or less than once"
593 end
609 end
594 end
610 end
595 end
611 end
596
612
597 def test_create_should_send_email_notification
613 def test_create_should_send_email_notification
598 ActionMailer::Base.deliveries.clear
614 ActionMailer::Base.deliveries.clear
599 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :estimated_hours => '1:30')
615 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :estimated_hours => '1:30')
600
616
601 assert issue.save
617 assert issue.save
602 assert_equal 1, ActionMailer::Base.deliveries.size
618 assert_equal 1, ActionMailer::Base.deliveries.size
603 end
619 end
604
620
605 def test_stale_issue_should_not_send_email_notification
621 def test_stale_issue_should_not_send_email_notification
606 ActionMailer::Base.deliveries.clear
622 ActionMailer::Base.deliveries.clear
607 issue = Issue.find(1)
623 issue = Issue.find(1)
608 stale = Issue.find(1)
624 stale = Issue.find(1)
609
625
610 issue.init_journal(User.find(1))
626 issue.init_journal(User.find(1))
611 issue.subject = 'Subjet update'
627 issue.subject = 'Subjet update'
612 assert issue.save
628 assert issue.save
613 assert_equal 1, ActionMailer::Base.deliveries.size
629 assert_equal 1, ActionMailer::Base.deliveries.size
614 ActionMailer::Base.deliveries.clear
630 ActionMailer::Base.deliveries.clear
615
631
616 stale.init_journal(User.find(1))
632 stale.init_journal(User.find(1))
617 stale.subject = 'Another subjet update'
633 stale.subject = 'Another subjet update'
618 assert_raise ActiveRecord::StaleObjectError do
634 assert_raise ActiveRecord::StaleObjectError do
619 stale.save
635 stale.save
620 end
636 end
621 assert ActionMailer::Base.deliveries.empty?
637 assert ActionMailer::Base.deliveries.empty?
622 end
638 end
623
639
624 def test_journalized_description
640 def test_journalized_description
625 IssueCustomField.delete_all
641 IssueCustomField.delete_all
626
642
627 i = Issue.first
643 i = Issue.first
628 old_description = i.description
644 old_description = i.description
629 new_description = "This is the new description"
645 new_description = "This is the new description"
630
646
631 i.init_journal(User.find(2))
647 i.init_journal(User.find(2))
632 i.description = new_description
648 i.description = new_description
633 assert_difference 'Journal.count', 1 do
649 assert_difference 'Journal.count', 1 do
634 assert_difference 'JournalDetail.count', 1 do
650 assert_difference 'JournalDetail.count', 1 do
635 i.save!
651 i.save!
636 end
652 end
637 end
653 end
638
654
639 detail = JournalDetail.first(:order => 'id DESC')
655 detail = JournalDetail.first(:order => 'id DESC')
640 assert_equal i, detail.journal.journalized
656 assert_equal i, detail.journal.journalized
641 assert_equal 'attr', detail.property
657 assert_equal 'attr', detail.property
642 assert_equal 'description', detail.prop_key
658 assert_equal 'description', detail.prop_key
643 assert_equal old_description, detail.old_value
659 assert_equal old_description, detail.old_value
644 assert_equal new_description, detail.value
660 assert_equal new_description, detail.value
645 end
661 end
646
662
647 def test_saving_twice_should_not_duplicate_journal_details
663 def test_saving_twice_should_not_duplicate_journal_details
648 i = Issue.find(:first)
664 i = Issue.find(:first)
649 i.init_journal(User.find(2), 'Some notes')
665 i.init_journal(User.find(2), 'Some notes')
650 # initial changes
666 # initial changes
651 i.subject = 'New subject'
667 i.subject = 'New subject'
652 i.done_ratio = i.done_ratio + 10
668 i.done_ratio = i.done_ratio + 10
653 assert_difference 'Journal.count' do
669 assert_difference 'Journal.count' do
654 assert i.save
670 assert i.save
655 end
671 end
656 # 1 more change
672 # 1 more change
657 i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
673 i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
658 assert_no_difference 'Journal.count' do
674 assert_no_difference 'Journal.count' do
659 assert_difference 'JournalDetail.count', 1 do
675 assert_difference 'JournalDetail.count', 1 do
660 i.save
676 i.save
661 end
677 end
662 end
678 end
663 # no more change
679 # no more change
664 assert_no_difference 'Journal.count' do
680 assert_no_difference 'Journal.count' do
665 assert_no_difference 'JournalDetail.count' do
681 assert_no_difference 'JournalDetail.count' do
666 i.save
682 i.save
667 end
683 end
668 end
684 end
669 end
685 end
670
686
671 def test_all_dependent_issues
687 def test_all_dependent_issues
672 IssueRelation.delete_all
688 IssueRelation.delete_all
673 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
689 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
674 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
690 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
675 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_PRECEDES)
691 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_PRECEDES)
676
692
677 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
693 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
678 end
694 end
679
695
680 def test_all_dependent_issues_with_persistent_circular_dependency
696 def test_all_dependent_issues_with_persistent_circular_dependency
681 IssueRelation.delete_all
697 IssueRelation.delete_all
682 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
698 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
683 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
699 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
684 # Validation skipping
700 # Validation skipping
685 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_PRECEDES).save(false)
701 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_PRECEDES).save(false)
686
702
687 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
703 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
688 end
704 end
689
705
690 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
706 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
691 IssueRelation.delete_all
707 IssueRelation.delete_all
692 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES)
708 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES)
693 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_RELATES)
709 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_RELATES)
694 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_RELATES)
710 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_RELATES)
695 # Validation skipping
711 # Validation skipping
696 assert IssueRelation.new(:issue_from => Issue.find(8), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES).save(false)
712 assert IssueRelation.new(:issue_from => Issue.find(8), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES).save(false)
697 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_RELATES).save(false)
713 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_RELATES).save(false)
698
714
699 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
715 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
700 end
716 end
701
717
702 context "#done_ratio" do
718 context "#done_ratio" do
703 setup do
719 setup do
704 @issue = Issue.find(1)
720 @issue = Issue.find(1)
705 @issue_status = IssueStatus.find(1)
721 @issue_status = IssueStatus.find(1)
706 @issue_status.update_attribute(:default_done_ratio, 50)
722 @issue_status.update_attribute(:default_done_ratio, 50)
707 @issue2 = Issue.find(2)
723 @issue2 = Issue.find(2)
708 @issue_status2 = IssueStatus.find(2)
724 @issue_status2 = IssueStatus.find(2)
709 @issue_status2.update_attribute(:default_done_ratio, 0)
725 @issue_status2.update_attribute(:default_done_ratio, 0)
710 end
726 end
711
727
712 context "with Setting.issue_done_ratio using the issue_field" do
728 context "with Setting.issue_done_ratio using the issue_field" do
713 setup do
729 setup do
714 Setting.issue_done_ratio = 'issue_field'
730 Setting.issue_done_ratio = 'issue_field'
715 end
731 end
716
732
717 should "read the issue's field" do
733 should "read the issue's field" do
718 assert_equal 0, @issue.done_ratio
734 assert_equal 0, @issue.done_ratio
719 assert_equal 30, @issue2.done_ratio
735 assert_equal 30, @issue2.done_ratio
720 end
736 end
721 end
737 end
722
738
723 context "with Setting.issue_done_ratio using the issue_status" do
739 context "with Setting.issue_done_ratio using the issue_status" do
724 setup do
740 setup do
725 Setting.issue_done_ratio = 'issue_status'
741 Setting.issue_done_ratio = 'issue_status'
726 end
742 end
727
743
728 should "read the Issue Status's default done ratio" do
744 should "read the Issue Status's default done ratio" do
729 assert_equal 50, @issue.done_ratio
745 assert_equal 50, @issue.done_ratio
730 assert_equal 0, @issue2.done_ratio
746 assert_equal 0, @issue2.done_ratio
731 end
747 end
732 end
748 end
733 end
749 end
734
750
735 context "#update_done_ratio_from_issue_status" do
751 context "#update_done_ratio_from_issue_status" do
736 setup do
752 setup do
737 @issue = Issue.find(1)
753 @issue = Issue.find(1)
738 @issue_status = IssueStatus.find(1)
754 @issue_status = IssueStatus.find(1)
739 @issue_status.update_attribute(:default_done_ratio, 50)
755 @issue_status.update_attribute(:default_done_ratio, 50)
740 @issue2 = Issue.find(2)
756 @issue2 = Issue.find(2)
741 @issue_status2 = IssueStatus.find(2)
757 @issue_status2 = IssueStatus.find(2)
742 @issue_status2.update_attribute(:default_done_ratio, 0)
758 @issue_status2.update_attribute(:default_done_ratio, 0)
743 end
759 end
744
760
745 context "with Setting.issue_done_ratio using the issue_field" do
761 context "with Setting.issue_done_ratio using the issue_field" do
746 setup do
762 setup do
747 Setting.issue_done_ratio = 'issue_field'
763 Setting.issue_done_ratio = 'issue_field'
748 end
764 end
749
765
750 should "not change the issue" do
766 should "not change the issue" do
751 @issue.update_done_ratio_from_issue_status
767 @issue.update_done_ratio_from_issue_status
752 @issue2.update_done_ratio_from_issue_status
768 @issue2.update_done_ratio_from_issue_status
753
769
754 assert_equal 0, @issue.read_attribute(:done_ratio)
770 assert_equal 0, @issue.read_attribute(:done_ratio)
755 assert_equal 30, @issue2.read_attribute(:done_ratio)
771 assert_equal 30, @issue2.read_attribute(:done_ratio)
756 end
772 end
757 end
773 end
758
774
759 context "with Setting.issue_done_ratio using the issue_status" do
775 context "with Setting.issue_done_ratio using the issue_status" do
760 setup do
776 setup do
761 Setting.issue_done_ratio = 'issue_status'
777 Setting.issue_done_ratio = 'issue_status'
762 end
778 end
763
779
764 should "change the issue's done ratio" do
780 should "change the issue's done ratio" do
765 @issue.update_done_ratio_from_issue_status
781 @issue.update_done_ratio_from_issue_status
766 @issue2.update_done_ratio_from_issue_status
782 @issue2.update_done_ratio_from_issue_status
767
783
768 assert_equal 50, @issue.read_attribute(:done_ratio)
784 assert_equal 50, @issue.read_attribute(:done_ratio)
769 assert_equal 0, @issue2.read_attribute(:done_ratio)
785 assert_equal 0, @issue2.read_attribute(:done_ratio)
770 end
786 end
771 end
787 end
772 end
788 end
773
789
774 test "#by_tracker" do
790 test "#by_tracker" do
775 groups = Issue.by_tracker(Project.find(1))
791 groups = Issue.by_tracker(Project.find(1))
776 assert_equal 3, groups.size
792 assert_equal 3, groups.size
777 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
793 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
778 end
794 end
779
795
780 test "#by_version" do
796 test "#by_version" do
781 groups = Issue.by_version(Project.find(1))
797 groups = Issue.by_version(Project.find(1))
782 assert_equal 3, groups.size
798 assert_equal 3, groups.size
783 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
799 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
784 end
800 end
785
801
786 test "#by_priority" do
802 test "#by_priority" do
787 groups = Issue.by_priority(Project.find(1))
803 groups = Issue.by_priority(Project.find(1))
788 assert_equal 4, groups.size
804 assert_equal 4, groups.size
789 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
805 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
790 end
806 end
791
807
792 test "#by_category" do
808 test "#by_category" do
793 groups = Issue.by_category(Project.find(1))
809 groups = Issue.by_category(Project.find(1))
794 assert_equal 2, groups.size
810 assert_equal 2, groups.size
795 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
811 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
796 end
812 end
797
813
798 test "#by_assigned_to" do
814 test "#by_assigned_to" do
799 groups = Issue.by_assigned_to(Project.find(1))
815 groups = Issue.by_assigned_to(Project.find(1))
800 assert_equal 2, groups.size
816 assert_equal 2, groups.size
801 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
817 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
802 end
818 end
803
819
804 test "#by_author" do
820 test "#by_author" do
805 groups = Issue.by_author(Project.find(1))
821 groups = Issue.by_author(Project.find(1))
806 assert_equal 4, groups.size
822 assert_equal 4, groups.size
807 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
823 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
808 end
824 end
809
825
810 test "#by_subproject" do
826 test "#by_subproject" do
811 groups = Issue.by_subproject(Project.find(1))
827 groups = Issue.by_subproject(Project.find(1))
812 assert_equal 2, groups.size
828 assert_equal 2, groups.size
813 assert_equal 5, groups.inject(0) {|sum, group| sum + group['total'].to_i}
829 assert_equal 5, groups.inject(0) {|sum, group| sum + group['total'].to_i}
814 end
830 end
815
831
816
832
817 context ".allowed_target_projects_on_move" do
833 context ".allowed_target_projects_on_move" do
818 should "return all active projects for admin users" do
834 should "return all active projects for admin users" do
819 User.current = User.find(1)
835 User.current = User.find(1)
820 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
836 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
821 end
837 end
822
838
823 should "return allowed projects for non admin users" do
839 should "return allowed projects for non admin users" do
824 User.current = User.find(2)
840 User.current = User.find(2)
825 Role.non_member.remove_permission! :move_issues
841 Role.non_member.remove_permission! :move_issues
826 assert_equal 3, Issue.allowed_target_projects_on_move.size
842 assert_equal 3, Issue.allowed_target_projects_on_move.size
827
843
828 Role.non_member.add_permission! :move_issues
844 Role.non_member.add_permission! :move_issues
829 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
845 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
830 end
846 end
831 end
847 end
832
848
833 def test_recently_updated_with_limit_scopes
849 def test_recently_updated_with_limit_scopes
834 #should return the last updated issue
850 #should return the last updated issue
835 assert_equal 1, Issue.recently_updated.with_limit(1).length
851 assert_equal 1, Issue.recently_updated.with_limit(1).length
836 assert_equal Issue.find(:first, :order => "updated_on DESC"), Issue.recently_updated.with_limit(1).first
852 assert_equal Issue.find(:first, :order => "updated_on DESC"), Issue.recently_updated.with_limit(1).first
837 end
853 end
838
854
839 def test_on_active_projects_scope
855 def test_on_active_projects_scope
840 assert Project.find(2).archive
856 assert Project.find(2).archive
841
857
842 before = Issue.on_active_project.length
858 before = Issue.on_active_project.length
843 # test inclusion to results
859 # test inclusion to results
844 issue = Issue.generate_for_project!(Project.find(1), :tracker => Project.find(2).trackers.first)
860 issue = Issue.generate_for_project!(Project.find(1), :tracker => Project.find(2).trackers.first)
845 assert_equal before + 1, Issue.on_active_project.length
861 assert_equal before + 1, Issue.on_active_project.length
846
862
847 # Move to an archived project
863 # Move to an archived project
848 issue.project = Project.find(2)
864 issue.project = Project.find(2)
849 assert issue.save
865 assert issue.save
850 assert_equal before, Issue.on_active_project.length
866 assert_equal before, Issue.on_active_project.length
851 end
867 end
852
868
853 context "Issue#recipients" do
869 context "Issue#recipients" do
854 setup do
870 setup do
855 @project = Project.find(1)
871 @project = Project.find(1)
856 @author = User.generate_with_protected!
872 @author = User.generate_with_protected!
857 @assignee = User.generate_with_protected!
873 @assignee = User.generate_with_protected!
858 @issue = Issue.generate_for_project!(@project, :assigned_to => @assignee, :author => @author)
874 @issue = Issue.generate_for_project!(@project, :assigned_to => @assignee, :author => @author)
859 end
875 end
860
876
861 should "include project recipients" do
877 should "include project recipients" do
862 assert @project.recipients.present?
878 assert @project.recipients.present?
863 @project.recipients.each do |project_recipient|
879 @project.recipients.each do |project_recipient|
864 assert @issue.recipients.include?(project_recipient)
880 assert @issue.recipients.include?(project_recipient)
865 end
881 end
866 end
882 end
867
883
868 should "include the author if the author is active" do
884 should "include the author if the author is active" do
869 assert @issue.author, "No author set for Issue"
885 assert @issue.author, "No author set for Issue"
870 assert @issue.recipients.include?(@issue.author.mail)
886 assert @issue.recipients.include?(@issue.author.mail)
871 end
887 end
872
888
873 should "include the assigned to user if the assigned to user is active" do
889 should "include the assigned to user if the assigned to user is active" do
874 assert @issue.assigned_to, "No assigned_to set for Issue"
890 assert @issue.assigned_to, "No assigned_to set for Issue"
875 assert @issue.recipients.include?(@issue.assigned_to.mail)
891 assert @issue.recipients.include?(@issue.assigned_to.mail)
876 end
892 end
877
893
878 should "not include users who opt out of all email" do
894 should "not include users who opt out of all email" do
879 @author.update_attribute(:mail_notification, :none)
895 @author.update_attribute(:mail_notification, :none)
880
896
881 assert !@issue.recipients.include?(@issue.author.mail)
897 assert !@issue.recipients.include?(@issue.author.mail)
882 end
898 end
883
899
884 should "not include the issue author if they are only notified of assigned issues" do
900 should "not include the issue author if they are only notified of assigned issues" do
885 @author.update_attribute(:mail_notification, :only_assigned)
901 @author.update_attribute(:mail_notification, :only_assigned)
886
902
887 assert !@issue.recipients.include?(@issue.author.mail)
903 assert !@issue.recipients.include?(@issue.author.mail)
888 end
904 end
889
905
890 should "not include the assigned user if they are only notified of owned issues" do
906 should "not include the assigned user if they are only notified of owned issues" do
891 @assignee.update_attribute(:mail_notification, :only_owner)
907 @assignee.update_attribute(:mail_notification, :only_owner)
892
908
893 assert !@issue.recipients.include?(@issue.assigned_to.mail)
909 assert !@issue.recipients.include?(@issue.assigned_to.mail)
894 end
910 end
895
911
896 end
912 end
897 end
913 end
General Comments 0
You need to be logged in to leave comments. Login now