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