##// END OF EJS Templates
Removed after_initialize methods....
Jean-Philippe Lang -
r8168:9e83ee2ddf3c
parent child
Show More
@@ -1,130 +1,131
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 'net/ldap'
19 19 require 'iconv'
20 20
21 21 class AuthSourceLdap < AuthSource
22 22 validates_presence_of :host, :port, :attr_login
23 23 validates_length_of :name, :host, :maximum => 60, :allow_nil => true
24 24 validates_length_of :account, :account_password, :base_dn, :maximum => 255, :allow_nil => true
25 25 validates_length_of :attr_login, :attr_firstname, :attr_lastname, :attr_mail, :maximum => 30, :allow_nil => true
26 26 validates_numericality_of :port, :only_integer => true
27 27
28 28 before_validation :strip_ldap_attributes
29 29
30 def after_initialize
30 def initialize(attributes=nil, *args)
31 super
31 32 self.port = 389 if self.port == 0
32 33 end
33 34
34 35 def authenticate(login, password)
35 36 return nil if login.blank? || password.blank?
36 37 attrs = get_user_dn(login)
37 38
38 39 if attrs && attrs[:dn] && authenticate_dn(attrs[:dn], password)
39 40 logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
40 41 return attrs.except(:dn)
41 42 end
42 43 rescue Net::LDAP::LdapError => text
43 44 raise "LdapError: " + text
44 45 end
45 46
46 47 # test the connection to the LDAP
47 48 def test_connection
48 49 ldap_con = initialize_ldap_con(self.account, self.account_password)
49 50 ldap_con.open { }
50 51 rescue Net::LDAP::LdapError => text
51 52 raise "LdapError: " + text
52 53 end
53 54
54 55 def auth_method_name
55 56 "LDAP"
56 57 end
57 58
58 59 private
59 60
60 61 def strip_ldap_attributes
61 62 [:attr_login, :attr_firstname, :attr_lastname, :attr_mail].each do |attr|
62 63 write_attribute(attr, read_attribute(attr).strip) unless read_attribute(attr).nil?
63 64 end
64 65 end
65 66
66 67 def initialize_ldap_con(ldap_user, ldap_password)
67 68 options = { :host => self.host,
68 69 :port => self.port,
69 70 :encryption => (self.tls ? :simple_tls : nil)
70 71 }
71 72 options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank?
72 73 Net::LDAP.new options
73 74 end
74 75
75 76 def get_user_attributes_from_ldap_entry(entry)
76 77 {
77 78 :dn => entry.dn,
78 79 :firstname => AuthSourceLdap.get_attr(entry, self.attr_firstname),
79 80 :lastname => AuthSourceLdap.get_attr(entry, self.attr_lastname),
80 81 :mail => AuthSourceLdap.get_attr(entry, self.attr_mail),
81 82 :auth_source_id => self.id
82 83 }
83 84 end
84 85
85 86 # Return the attributes needed for the LDAP search. It will only
86 87 # include the user attributes if on-the-fly registration is enabled
87 88 def search_attributes
88 89 if onthefly_register?
89 90 ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail]
90 91 else
91 92 ['dn']
92 93 end
93 94 end
94 95
95 96 # Check if a DN (user record) authenticates with the password
96 97 def authenticate_dn(dn, password)
97 98 if dn.present? && password.present?
98 99 initialize_ldap_con(dn, password).bind
99 100 end
100 101 end
101 102
102 103 # Get the user's dn and any attributes for them, given their login
103 104 def get_user_dn(login)
104 105 ldap_con = initialize_ldap_con(self.account, self.account_password)
105 106 login_filter = Net::LDAP::Filter.eq( self.attr_login, login )
106 107 object_filter = Net::LDAP::Filter.eq( "objectClass", "*" )
107 108 attrs = {}
108 109
109 110 ldap_con.search( :base => self.base_dn,
110 111 :filter => object_filter & login_filter,
111 112 :attributes=> search_attributes) do |entry|
112 113
113 114 if onthefly_register?
114 115 attrs = get_user_attributes_from_ldap_entry(entry)
115 116 else
116 117 attrs = {:dn => entry.dn}
117 118 end
118 119
119 120 logger.debug "DN found for #{login}: #{attrs[:dn]}" if logger && logger.debug?
120 121 end
121 122
122 123 attrs
123 124 end
124 125
125 126 def self.get_attr(entry, attr_name)
126 127 if !attr_name.blank?
127 128 entry[attr_name].is_a?(Array) ? entry[attr_name].first : entry[attr_name]
128 129 end
129 130 end
130 131 end
@@ -1,73 +1,74
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 CustomValue < ActiveRecord::Base
19 19 belongs_to :custom_field
20 20 belongs_to :customized, :polymorphic => true
21 21
22 22 validate :validate_custom_value
23 23
24 def after_initialize
24 def initialize(attributes=nil, *args)
25 super
25 26 if new_record? && custom_field && (customized_type.blank? || (customized && customized.new_record?))
26 27 self.value ||= custom_field.default_value
27 28 end
28 29 end
29 30
30 31 # Returns true if the boolean custom value is true
31 32 def true?
32 33 self.value == '1'
33 34 end
34 35
35 36 def editable?
36 37 custom_field.editable?
37 38 end
38 39
39 40 def visible?
40 41 custom_field.visible?
41 42 end
42 43
43 44 def required?
44 45 custom_field.is_required?
45 46 end
46 47
47 48 def to_s
48 49 value.to_s
49 50 end
50 51
51 52 protected
52 53 def validate_custom_value
53 54 if value.blank?
54 55 errors.add(:value, :blank) if custom_field.is_required? and value.blank?
55 56 else
56 57 errors.add(:value, :invalid) unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
57 58 errors.add(:value, :too_short, :count => custom_field.min_length) if custom_field.min_length > 0 and value.length < custom_field.min_length
58 59 errors.add(:value, :too_long, :count => custom_field.max_length) if custom_field.max_length > 0 and value.length > custom_field.max_length
59 60
60 61 # Format specific validations
61 62 case custom_field.field_format
62 63 when 'int'
63 64 errors.add(:value, :not_a_number) unless value =~ /^[+-]?\d+$/
64 65 when 'float'
65 66 begin; Kernel.Float(value); rescue; errors.add(:value, :invalid) end
66 67 when 'date'
67 68 errors.add(:value, :not_a_date) unless value =~ /^\d{4}-\d{2}-\d{2}$/ && begin; value.to_date; rescue; false end
68 69 when 'list'
69 70 errors.add(:value, :inclusion) unless custom_field.possible_values.include?(value)
70 71 end
71 72 end
72 73 end
73 74 end
@@ -1,52 +1,53
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 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 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 def after_initialize
39 def initialize(attributes=nil, *args)
40 super
40 41 if new_record?
41 42 self.category ||= DocumentCategory.default
42 43 end
43 44 end
44 45
45 46 def updated_on
46 47 unless @updated_on
47 48 a = attachments.find(:first, :order => 'created_on DESC')
48 49 @updated_on = (a && a.created_on) || created_on
49 50 end
50 51 @updated_on
51 52 end
52 53 end
@@ -1,991 +1,992
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 => 'Principal', :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_add => :attachment_added, :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 validate :validate_issue
62 62
63 63 named_scope :visible, lambda {|*args| { :include => :project,
64 64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65 65
66 66 named_scope :open, lambda {|*args|
67 67 is_closed = args.size > 0 ? !args.first : false
68 68 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
69 69 }
70 70
71 71 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
72 72 named_scope :with_limit, lambda { |limit| { :limit => limit} }
73 73 named_scope :on_active_project, :include => [:status, :project, :tracker],
74 74 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
75 75
76 76 named_scope :without_version, lambda {
77 77 {
78 78 :conditions => { :fixed_version_id => nil}
79 79 }
80 80 }
81 81
82 82 named_scope :with_query, lambda {|query|
83 83 {
84 84 :conditions => Query.merge_conditions(query.statement)
85 85 }
86 86 }
87 87
88 88 before_create :default_assign
89 89 before_save :close_duplicates, :update_done_ratio_from_issue_status
90 90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
91 91 after_destroy :update_parent_attributes
92 92
93 93 # Returns a SQL conditions string used to find all issues visible by the specified user
94 94 def self.visible_condition(user, options={})
95 95 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
96 96 case role.issues_visibility
97 97 when 'all'
98 98 nil
99 99 when 'default'
100 100 user_ids = [user.id] + user.groups.map(&:id)
101 101 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
102 102 when 'own'
103 103 user_ids = [user.id] + user.groups.map(&:id)
104 104 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
105 105 else
106 106 '1=0'
107 107 end
108 108 end
109 109 end
110 110
111 111 # Returns true if usr or current user is allowed to view the issue
112 112 def visible?(usr=nil)
113 113 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
114 114 case role.issues_visibility
115 115 when 'all'
116 116 true
117 117 when 'default'
118 118 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
119 119 when 'own'
120 120 self.author == user || user.is_or_belongs_to?(assigned_to)
121 121 else
122 122 false
123 123 end
124 124 end
125 125 end
126 126
127 def after_initialize
127 def initialize(attributes=nil, *args)
128 super
128 129 if new_record?
129 130 # set default values for new records only
130 131 self.status ||= IssueStatus.default
131 132 self.priority ||= IssuePriority.default
132 133 end
133 134 end
134 135
135 136 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
136 137 def available_custom_fields
137 138 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
138 139 end
139 140
140 141 def copy_from(arg)
141 142 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
142 143 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
143 144 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
144 145 self.status = issue.status
145 146 self
146 147 end
147 148
148 149 # Moves/copies an issue to a new project and tracker
149 150 # Returns the moved/copied issue on success, false on failure
150 151 def move_to_project(*args)
151 152 ret = Issue.transaction do
152 153 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
153 154 end || false
154 155 end
155 156
156 157 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
157 158 options ||= {}
158 159
159 160 if options[:copy]
160 161 issue = self.class.new.copy_from(self)
161 162 else
162 163 issue = self
163 164 issue.init_journal(User.current, options[:notes])
164 165 end
165 166
166 167 if new_project && issue.project_id != new_project.id
167 168 # delete issue relations
168 169 unless Setting.cross_project_issue_relations?
169 170 issue.relations_from.clear
170 171 issue.relations_to.clear
171 172 end
172 173 # issue is moved to another project
173 174 # reassign to the category with same name if any
174 175 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
175 176 issue.category = new_category
176 177 # Keep the fixed_version if it's still valid in the new_project
177 178 unless new_project.shared_versions.include?(issue.fixed_version)
178 179 issue.fixed_version = nil
179 180 end
180 181 issue.project = new_project
181 182 if issue.parent && issue.parent.project_id != issue.project_id
182 183 issue.parent_issue_id = nil
183 184 end
184 185 end
185 186 if new_tracker
186 187 issue.tracker = new_tracker
187 188 issue.reset_custom_values!
188 189 end
189 190 if options[:copy]
190 191 issue.author = User.current
191 192 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
192 193 issue.status = if options[:attributes] && options[:attributes][:status_id]
193 194 IssueStatus.find_by_id(options[:attributes][:status_id])
194 195 else
195 196 self.status
196 197 end
197 198 end
198 199 # Allow bulk setting of attributes on the issue
199 200 if options[:attributes]
200 201 issue.attributes = options[:attributes]
201 202 end
202 203 if options[:copy] && options[:notes].present?
203 204 issue.init_journal(User.current, options[:notes])
204 205 issue.current_journal.notify = false
205 206 end
206 207 if issue.save
207 208 unless options[:copy]
208 209 # Manually update project_id on related time entries
209 210 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
210 211
211 212 issue.children.each do |child|
212 213 unless child.move_to_project_without_transaction(new_project)
213 214 # Move failed and transaction was rollback'd
214 215 return false
215 216 end
216 217 end
217 218 end
218 219 else
219 220 return false
220 221 end
221 222 issue
222 223 end
223 224
224 225 def status_id=(sid)
225 226 self.status = nil
226 227 write_attribute(:status_id, sid)
227 228 end
228 229
229 230 def priority_id=(pid)
230 231 self.priority = nil
231 232 write_attribute(:priority_id, pid)
232 233 end
233 234
234 235 def tracker_id=(tid)
235 236 self.tracker = nil
236 237 result = write_attribute(:tracker_id, tid)
237 238 @custom_field_values = nil
238 239 result
239 240 end
240 241
241 242 def description=(arg)
242 243 if arg.is_a?(String)
243 244 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
244 245 end
245 246 write_attribute(:description, arg)
246 247 end
247 248
248 249 # Overrides attributes= so that project and tracker get assigned first
249 250 def attributes_with_project_and_tracker_first=(new_attributes, *args)
250 251 return if new_attributes.nil?
251 252 attrs = new_attributes.dup
252 253 attrs.stringify_keys!
253 254
254 255 %w(project project_id tracker tracker_id).each do |attr|
255 256 if attrs.has_key?(attr)
256 257 send "#{attr}=", attrs.delete(attr)
257 258 end
258 259 end
259 260 send :attributes_without_project_and_tracker_first=, attrs, *args
260 261 end
261 262 # Do not redefine alias chain on reload (see #4838)
262 263 alias_method_chain(:attributes=, :project_and_tracker_first) unless method_defined?(:attributes_without_project_and_tracker_first=)
263 264
264 265 def estimated_hours=(h)
265 266 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
266 267 end
267 268
268 269 safe_attributes 'tracker_id',
269 270 'status_id',
270 271 'category_id',
271 272 'assigned_to_id',
272 273 'priority_id',
273 274 'fixed_version_id',
274 275 'subject',
275 276 'description',
276 277 'start_date',
277 278 'due_date',
278 279 'done_ratio',
279 280 'estimated_hours',
280 281 'custom_field_values',
281 282 'custom_fields',
282 283 'lock_version',
283 284 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
284 285
285 286 safe_attributes 'status_id',
286 287 'assigned_to_id',
287 288 'fixed_version_id',
288 289 'done_ratio',
289 290 'lock_version',
290 291 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
291 292
292 293 safe_attributes 'watcher_user_ids',
293 294 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
294 295
295 296 safe_attributes 'is_private',
296 297 :if => lambda {|issue, user|
297 298 user.allowed_to?(:set_issues_private, issue.project) ||
298 299 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
299 300 }
300 301
301 302 safe_attributes 'parent_issue_id',
302 303 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
303 304 user.allowed_to?(:manage_subtasks, issue.project)}
304 305
305 306 # Safely sets attributes
306 307 # Should be called from controllers instead of #attributes=
307 308 # attr_accessible is too rough because we still want things like
308 309 # Issue.new(:project => foo) to work
309 310 # TODO: move workflow/permission checks from controllers to here
310 311 def safe_attributes=(attrs, user=User.current)
311 312 return unless attrs.is_a?(Hash)
312 313
313 314 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
314 315 attrs = delete_unsafe_attributes(attrs, user)
315 316 return if attrs.empty?
316 317
317 318 # Tracker must be set before since new_statuses_allowed_to depends on it.
318 319 if t = attrs.delete('tracker_id')
319 320 self.tracker_id = t
320 321 end
321 322
322 323 if attrs['status_id']
323 324 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
324 325 attrs.delete('status_id')
325 326 end
326 327 end
327 328
328 329 unless leaf?
329 330 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
330 331 end
331 332
332 333 if attrs['parent_issue_id'].present?
333 334 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
334 335 end
335 336
336 337 # mass-assignment security bypass
337 338 self.send :attributes=, attrs, false
338 339 end
339 340
340 341 def done_ratio
341 342 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
342 343 status.default_done_ratio
343 344 else
344 345 read_attribute(:done_ratio)
345 346 end
346 347 end
347 348
348 349 def self.use_status_for_done_ratio?
349 350 Setting.issue_done_ratio == 'issue_status'
350 351 end
351 352
352 353 def self.use_field_for_done_ratio?
353 354 Setting.issue_done_ratio == 'issue_field'
354 355 end
355 356
356 357 def validate_issue
357 358 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
358 359 errors.add :due_date, :not_a_date
359 360 end
360 361
361 362 if self.due_date and self.start_date and self.due_date < self.start_date
362 363 errors.add :due_date, :greater_than_start_date
363 364 end
364 365
365 366 if start_date && soonest_start && start_date < soonest_start
366 367 errors.add :start_date, :invalid
367 368 end
368 369
369 370 if fixed_version
370 371 if !assignable_versions.include?(fixed_version)
371 372 errors.add :fixed_version_id, :inclusion
372 373 elsif reopened? && fixed_version.closed?
373 374 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
374 375 end
375 376 end
376 377
377 378 # Checks that the issue can not be added/moved to a disabled tracker
378 379 if project && (tracker_id_changed? || project_id_changed?)
379 380 unless project.trackers.include?(tracker)
380 381 errors.add :tracker_id, :inclusion
381 382 end
382 383 end
383 384
384 385 # Checks parent issue assignment
385 386 if @parent_issue
386 387 if @parent_issue.project_id != project_id
387 388 errors.add :parent_issue_id, :not_same_project
388 389 elsif !new_record?
389 390 # moving an existing issue
390 391 if @parent_issue.root_id != root_id
391 392 # we can always move to another tree
392 393 elsif move_possible?(@parent_issue)
393 394 # move accepted inside tree
394 395 else
395 396 errors.add :parent_issue_id, :not_a_valid_parent
396 397 end
397 398 end
398 399 end
399 400 end
400 401
401 402 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
402 403 # even if the user turns off the setting later
403 404 def update_done_ratio_from_issue_status
404 405 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
405 406 self.done_ratio = status.default_done_ratio
406 407 end
407 408 end
408 409
409 410 def init_journal(user, notes = "")
410 411 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
411 412 @issue_before_change = self.clone
412 413 @issue_before_change.status = self.status
413 414 @custom_values_before_change = {}
414 415 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
415 416 # Make sure updated_on is updated when adding a note.
416 417 updated_on_will_change!
417 418 @current_journal
418 419 end
419 420
420 421 # Return true if the issue is closed, otherwise false
421 422 def closed?
422 423 self.status.is_closed?
423 424 end
424 425
425 426 # Return true if the issue is being reopened
426 427 def reopened?
427 428 if !new_record? && status_id_changed?
428 429 status_was = IssueStatus.find_by_id(status_id_was)
429 430 status_new = IssueStatus.find_by_id(status_id)
430 431 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
431 432 return true
432 433 end
433 434 end
434 435 false
435 436 end
436 437
437 438 # Return true if the issue is being closed
438 439 def closing?
439 440 if !new_record? && status_id_changed?
440 441 status_was = IssueStatus.find_by_id(status_id_was)
441 442 status_new = IssueStatus.find_by_id(status_id)
442 443 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
443 444 return true
444 445 end
445 446 end
446 447 false
447 448 end
448 449
449 450 # Returns true if the issue is overdue
450 451 def overdue?
451 452 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
452 453 end
453 454
454 455 # Is the amount of work done less than it should for the due date
455 456 def behind_schedule?
456 457 return false if start_date.nil? || due_date.nil?
457 458 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
458 459 return done_date <= Date.today
459 460 end
460 461
461 462 # Does this issue have children?
462 463 def children?
463 464 !leaf?
464 465 end
465 466
466 467 # Users the issue can be assigned to
467 468 def assignable_users
468 469 users = project.assignable_users
469 470 users << author if author
470 471 users << assigned_to if assigned_to
471 472 users.uniq.sort
472 473 end
473 474
474 475 # Versions that the issue can be assigned to
475 476 def assignable_versions
476 477 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
477 478 end
478 479
479 480 # Returns true if this issue is blocked by another issue that is still open
480 481 def blocked?
481 482 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
482 483 end
483 484
484 485 # Returns an array of status that user is able to apply
485 486 def new_statuses_allowed_to(user, include_default=false)
486 487 statuses = status.find_new_statuses_allowed_to(
487 488 user.roles_for_project(project),
488 489 tracker,
489 490 author == user,
490 491 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
491 492 )
492 493 statuses << status unless statuses.empty?
493 494 statuses << IssueStatus.default if include_default
494 495 statuses = statuses.uniq.sort
495 496 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
496 497 end
497 498
498 499 # Returns the mail adresses of users that should be notified
499 500 def recipients
500 501 notified = project.notified_users
501 502 # Author and assignee are always notified unless they have been
502 503 # locked or don't want to be notified
503 504 notified << author if author && author.active? && author.notify_about?(self)
504 505 if assigned_to
505 506 if assigned_to.is_a?(Group)
506 507 notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)}
507 508 else
508 509 notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self)
509 510 end
510 511 end
511 512 notified.uniq!
512 513 # Remove users that can not view the issue
513 514 notified.reject! {|user| !visible?(user)}
514 515 notified.collect(&:mail)
515 516 end
516 517
517 518 # Returns the number of hours spent on this issue
518 519 def spent_hours
519 520 @spent_hours ||= time_entries.sum(:hours) || 0
520 521 end
521 522
522 523 # Returns the total number of hours spent on this issue and its descendants
523 524 #
524 525 # Example:
525 526 # spent_hours => 0.0
526 527 # spent_hours => 50.2
527 528 def total_spent_hours
528 529 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
529 530 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
530 531 end
531 532
532 533 def relations
533 534 @relations ||= (relations_from + relations_to).sort
534 535 end
535 536
536 537 # Preloads relations for a collection of issues
537 538 def self.load_relations(issues)
538 539 if issues.any?
539 540 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
540 541 issues.each do |issue|
541 542 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
542 543 end
543 544 end
544 545 end
545 546
546 547 # Preloads visible spent time for a collection of issues
547 548 def self.load_visible_spent_hours(issues, user=User.current)
548 549 if issues.any?
549 550 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
550 551 issues.each do |issue|
551 552 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
552 553 end
553 554 end
554 555 end
555 556
556 557 # Finds an issue relation given its id.
557 558 def find_relation(relation_id)
558 559 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
559 560 end
560 561
561 562 def all_dependent_issues(except=[])
562 563 except << self
563 564 dependencies = []
564 565 relations_from.each do |relation|
565 566 if relation.issue_to && !except.include?(relation.issue_to)
566 567 dependencies << relation.issue_to
567 568 dependencies += relation.issue_to.all_dependent_issues(except)
568 569 end
569 570 end
570 571 dependencies
571 572 end
572 573
573 574 # Returns an array of issues that duplicate this one
574 575 def duplicates
575 576 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
576 577 end
577 578
578 579 # Returns the due date or the target due date if any
579 580 # Used on gantt chart
580 581 def due_before
581 582 due_date || (fixed_version ? fixed_version.effective_date : nil)
582 583 end
583 584
584 585 # Returns the time scheduled for this issue.
585 586 #
586 587 # Example:
587 588 # Start Date: 2/26/09, End Date: 3/04/09
588 589 # duration => 6
589 590 def duration
590 591 (start_date && due_date) ? due_date - start_date : 0
591 592 end
592 593
593 594 def soonest_start
594 595 @soonest_start ||= (
595 596 relations_to.collect{|relation| relation.successor_soonest_start} +
596 597 ancestors.collect(&:soonest_start)
597 598 ).compact.max
598 599 end
599 600
600 601 def reschedule_after(date)
601 602 return if date.nil?
602 603 if leaf?
603 604 if start_date.nil? || start_date < date
604 605 self.start_date, self.due_date = date, date + duration
605 606 save
606 607 end
607 608 else
608 609 leaves.each do |leaf|
609 610 leaf.reschedule_after(date)
610 611 end
611 612 end
612 613 end
613 614
614 615 def <=>(issue)
615 616 if issue.nil?
616 617 -1
617 618 elsif root_id != issue.root_id
618 619 (root_id || 0) <=> (issue.root_id || 0)
619 620 else
620 621 (lft || 0) <=> (issue.lft || 0)
621 622 end
622 623 end
623 624
624 625 def to_s
625 626 "#{tracker} ##{id}: #{subject}"
626 627 end
627 628
628 629 # Returns a string of css classes that apply to the issue
629 630 def css_classes
630 631 s = "issue status-#{status.position} priority-#{priority.position}"
631 632 s << ' closed' if closed?
632 633 s << ' overdue' if overdue?
633 634 s << ' child' if child?
634 635 s << ' parent' unless leaf?
635 636 s << ' private' if is_private?
636 637 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
637 638 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
638 639 s
639 640 end
640 641
641 642 # Saves an issue, time_entry, attachments, and a journal from the parameters
642 643 # Returns false if save fails
643 644 def save_issue_with_child_records(params, existing_time_entry=nil)
644 645 Issue.transaction do
645 646 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
646 647 @time_entry = existing_time_entry || TimeEntry.new
647 648 @time_entry.project = project
648 649 @time_entry.issue = self
649 650 @time_entry.user = User.current
650 651 @time_entry.spent_on = User.current.today
651 652 @time_entry.attributes = params[:time_entry]
652 653 self.time_entries << @time_entry
653 654 end
654 655
655 656 if valid?
656 657 attachments = Attachment.attach_files(self, params[:attachments])
657 658 # TODO: Rename hook
658 659 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
659 660 begin
660 661 if save
661 662 # TODO: Rename hook
662 663 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
663 664 else
664 665 raise ActiveRecord::Rollback
665 666 end
666 667 rescue ActiveRecord::StaleObjectError
667 668 attachments[:files].each(&:destroy)
668 669 errors.add :base, l(:notice_locking_conflict)
669 670 raise ActiveRecord::Rollback
670 671 end
671 672 end
672 673 end
673 674 end
674 675
675 676 # Unassigns issues from +version+ if it's no longer shared with issue's project
676 677 def self.update_versions_from_sharing_change(version)
677 678 # Update issues assigned to the version
678 679 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
679 680 end
680 681
681 682 # Unassigns issues from versions that are no longer shared
682 683 # after +project+ was moved
683 684 def self.update_versions_from_hierarchy_change(project)
684 685 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
685 686 # Update issues of the moved projects and issues assigned to a version of a moved project
686 687 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
687 688 end
688 689
689 690 def parent_issue_id=(arg)
690 691 parent_issue_id = arg.blank? ? nil : arg.to_i
691 692 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
692 693 @parent_issue.id
693 694 else
694 695 @parent_issue = nil
695 696 nil
696 697 end
697 698 end
698 699
699 700 def parent_issue_id
700 701 if instance_variable_defined? :@parent_issue
701 702 @parent_issue.nil? ? nil : @parent_issue.id
702 703 else
703 704 parent_id
704 705 end
705 706 end
706 707
707 708 # Extracted from the ReportsController.
708 709 def self.by_tracker(project)
709 710 count_and_group_by(:project => project,
710 711 :field => 'tracker_id',
711 712 :joins => Tracker.table_name)
712 713 end
713 714
714 715 def self.by_version(project)
715 716 count_and_group_by(:project => project,
716 717 :field => 'fixed_version_id',
717 718 :joins => Version.table_name)
718 719 end
719 720
720 721 def self.by_priority(project)
721 722 count_and_group_by(:project => project,
722 723 :field => 'priority_id',
723 724 :joins => IssuePriority.table_name)
724 725 end
725 726
726 727 def self.by_category(project)
727 728 count_and_group_by(:project => project,
728 729 :field => 'category_id',
729 730 :joins => IssueCategory.table_name)
730 731 end
731 732
732 733 def self.by_assigned_to(project)
733 734 count_and_group_by(:project => project,
734 735 :field => 'assigned_to_id',
735 736 :joins => User.table_name)
736 737 end
737 738
738 739 def self.by_author(project)
739 740 count_and_group_by(:project => project,
740 741 :field => 'author_id',
741 742 :joins => User.table_name)
742 743 end
743 744
744 745 def self.by_subproject(project)
745 746 ActiveRecord::Base.connection.select_all("select s.id as status_id,
746 747 s.is_closed as closed,
747 748 #{Issue.table_name}.project_id as project_id,
748 749 count(#{Issue.table_name}.id) as total
749 750 from
750 751 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
751 752 where
752 753 #{Issue.table_name}.status_id=s.id
753 754 and #{Issue.table_name}.project_id = #{Project.table_name}.id
754 755 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
755 756 and #{Issue.table_name}.project_id <> #{project.id}
756 757 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
757 758 end
758 759 # End ReportsController extraction
759 760
760 761 # Returns an array of projects that current user can move issues to
761 762 def self.allowed_target_projects_on_move
762 763 projects = []
763 764 if User.current.admin?
764 765 # admin is allowed to move issues to any active (visible) project
765 766 projects = Project.visible.all
766 767 elsif User.current.logged?
767 768 if Role.non_member.allowed_to?(:move_issues)
768 769 projects = Project.visible.all
769 770 else
770 771 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
771 772 end
772 773 end
773 774 projects
774 775 end
775 776
776 777 private
777 778
778 779 def update_nested_set_attributes
779 780 if root_id.nil?
780 781 # issue was just created
781 782 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
782 783 set_default_left_and_right
783 784 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
784 785 if @parent_issue
785 786 move_to_child_of(@parent_issue)
786 787 end
787 788 reload
788 789 elsif parent_issue_id != parent_id
789 790 former_parent_id = parent_id
790 791 # moving an existing issue
791 792 if @parent_issue && @parent_issue.root_id == root_id
792 793 # inside the same tree
793 794 move_to_child_of(@parent_issue)
794 795 else
795 796 # to another tree
796 797 unless root?
797 798 move_to_right_of(root)
798 799 reload
799 800 end
800 801 old_root_id = root_id
801 802 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
802 803 target_maxright = nested_set_scope.maximum(right_column_name) || 0
803 804 offset = target_maxright + 1 - lft
804 805 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
805 806 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
806 807 self[left_column_name] = lft + offset
807 808 self[right_column_name] = rgt + offset
808 809 if @parent_issue
809 810 move_to_child_of(@parent_issue)
810 811 end
811 812 end
812 813 reload
813 814 # delete invalid relations of all descendants
814 815 self_and_descendants.each do |issue|
815 816 issue.relations.each do |relation|
816 817 relation.destroy unless relation.valid?
817 818 end
818 819 end
819 820 # update former parent
820 821 recalculate_attributes_for(former_parent_id) if former_parent_id
821 822 end
822 823 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
823 824 end
824 825
825 826 def update_parent_attributes
826 827 recalculate_attributes_for(parent_id) if parent_id
827 828 end
828 829
829 830 def recalculate_attributes_for(issue_id)
830 831 if issue_id && p = Issue.find_by_id(issue_id)
831 832 # priority = highest priority of children
832 833 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
833 834 p.priority = IssuePriority.find_by_position(priority_position)
834 835 end
835 836
836 837 # start/due dates = lowest/highest dates of children
837 838 p.start_date = p.children.minimum(:start_date)
838 839 p.due_date = p.children.maximum(:due_date)
839 840 if p.start_date && p.due_date && p.due_date < p.start_date
840 841 p.start_date, p.due_date = p.due_date, p.start_date
841 842 end
842 843
843 844 # done ratio = weighted average ratio of leaves
844 845 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
845 846 leaves_count = p.leaves.count
846 847 if leaves_count > 0
847 848 average = p.leaves.average(:estimated_hours).to_f
848 849 if average == 0
849 850 average = 1
850 851 end
851 852 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
852 853 progress = done / (average * leaves_count)
853 854 p.done_ratio = progress.round
854 855 end
855 856 end
856 857
857 858 # estimate = sum of leaves estimates
858 859 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
859 860 p.estimated_hours = nil if p.estimated_hours == 0.0
860 861
861 862 # ancestors will be recursively updated
862 863 p.save(false)
863 864 end
864 865 end
865 866
866 867 # Update issues so their versions are not pointing to a
867 868 # fixed_version that is not shared with the issue's project
868 869 def self.update_versions(conditions=nil)
869 870 # Only need to update issues with a fixed_version from
870 871 # a different project and that is not systemwide shared
871 872 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
872 873 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
873 874 " AND #{Version.table_name}.sharing <> 'system'",
874 875 conditions),
875 876 :include => [:project, :fixed_version]
876 877 ).each do |issue|
877 878 next if issue.project.nil? || issue.fixed_version.nil?
878 879 unless issue.project.shared_versions.include?(issue.fixed_version)
879 880 issue.init_journal(User.current)
880 881 issue.fixed_version = nil
881 882 issue.save
882 883 end
883 884 end
884 885 end
885 886
886 887 # Callback on attachment deletion
887 888 def attachment_added(obj)
888 889 if @current_journal && !obj.new_record?
889 890 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
890 891 end
891 892 end
892 893
893 894 # Callback on attachment deletion
894 895 def attachment_removed(obj)
895 896 journal = init_journal(User.current)
896 897 journal.details << JournalDetail.new(:property => 'attachment',
897 898 :prop_key => obj.id,
898 899 :old_value => obj.filename)
899 900 journal.save
900 901 end
901 902
902 903 # Default assignment based on category
903 904 def default_assign
904 905 if assigned_to.nil? && category && category.assigned_to
905 906 self.assigned_to = category.assigned_to
906 907 end
907 908 end
908 909
909 910 # Updates start/due dates of following issues
910 911 def reschedule_following_issues
911 912 if start_date_changed? || due_date_changed?
912 913 relations_from.each do |relation|
913 914 relation.set_issue_to_dates
914 915 end
915 916 end
916 917 end
917 918
918 919 # Closes duplicates if the issue is being closed
919 920 def close_duplicates
920 921 if closing?
921 922 duplicates.each do |duplicate|
922 923 # Reload is need in case the duplicate was updated by a previous duplicate
923 924 duplicate.reload
924 925 # Don't re-close it if it's already closed
925 926 next if duplicate.closed?
926 927 # Same user and notes
927 928 if @current_journal
928 929 duplicate.init_journal(@current_journal.user, @current_journal.notes)
929 930 end
930 931 duplicate.update_attribute :status, self.status
931 932 end
932 933 end
933 934 end
934 935
935 936 # Saves the changes in a Journal
936 937 # Called after_save
937 938 def create_journal
938 939 if @current_journal
939 940 # attributes changes
940 941 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
941 942 before = @issue_before_change.send(c)
942 943 after = send(c)
943 944 next if before == after || (before.blank? && after.blank?)
944 945 @current_journal.details << JournalDetail.new(:property => 'attr',
945 946 :prop_key => c,
946 947 :old_value => @issue_before_change.send(c),
947 948 :value => send(c))
948 949 }
949 950 # custom fields changes
950 951 custom_values.each {|c|
951 952 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
952 953 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
953 954 @current_journal.details << JournalDetail.new(:property => 'cf',
954 955 :prop_key => c.custom_field_id,
955 956 :old_value => @custom_values_before_change[c.custom_field_id],
956 957 :value => c.value)
957 958 }
958 959 @current_journal.save
959 960 # reset current journal
960 961 init_journal @current_journal.user, @current_journal.notes
961 962 end
962 963 end
963 964
964 965 # Query generator for selecting groups of issue counts for a project
965 966 # based on specific criteria
966 967 #
967 968 # Options
968 969 # * project - Project to search in.
969 970 # * field - String. Issue field to key off of in the grouping.
970 971 # * joins - String. The table name to join against.
971 972 def self.count_and_group_by(options)
972 973 project = options.delete(:project)
973 974 select_field = options.delete(:field)
974 975 joins = options.delete(:joins)
975 976
976 977 where = "#{Issue.table_name}.#{select_field}=j.id"
977 978
978 979 ActiveRecord::Base.connection.select_all("select s.id as status_id,
979 980 s.is_closed as closed,
980 981 j.id as #{select_field},
981 982 count(#{Issue.table_name}.id) as total
982 983 from
983 984 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
984 985 where
985 986 #{Issue.table_name}.status_id=s.id
986 987 and #{where}
987 988 and #{Issue.table_name}.project_id=#{Project.table_name}.id
988 989 and #{visible_condition(User.current, :project => project)}
989 990 group by s.id, s.is_closed, j.id")
990 991 end
991 992 end
@@ -1,142 +1,143
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 IssueRelation < ActiveRecord::Base
19 19 belongs_to :issue_from, :class_name => 'Issue', :foreign_key => 'issue_from_id'
20 20 belongs_to :issue_to, :class_name => 'Issue', :foreign_key => 'issue_to_id'
21 21
22 22 TYPE_RELATES = "relates"
23 23 TYPE_DUPLICATES = "duplicates"
24 24 TYPE_DUPLICATED = "duplicated"
25 25 TYPE_BLOCKS = "blocks"
26 26 TYPE_BLOCKED = "blocked"
27 27 TYPE_PRECEDES = "precedes"
28 28 TYPE_FOLLOWS = "follows"
29 29
30 30 TYPES = { TYPE_RELATES => { :name => :label_relates_to, :sym_name => :label_relates_to, :order => 1, :sym => TYPE_RELATES },
31 31 TYPE_DUPLICATES => { :name => :label_duplicates, :sym_name => :label_duplicated_by, :order => 2, :sym => TYPE_DUPLICATED },
32 32 TYPE_DUPLICATED => { :name => :label_duplicated_by, :sym_name => :label_duplicates, :order => 3, :sym => TYPE_DUPLICATES, :reverse => TYPE_DUPLICATES },
33 33 TYPE_BLOCKS => { :name => :label_blocks, :sym_name => :label_blocked_by, :order => 4, :sym => TYPE_BLOCKED },
34 34 TYPE_BLOCKED => { :name => :label_blocked_by, :sym_name => :label_blocks, :order => 5, :sym => TYPE_BLOCKS, :reverse => TYPE_BLOCKS },
35 35 TYPE_PRECEDES => { :name => :label_precedes, :sym_name => :label_follows, :order => 6, :sym => TYPE_FOLLOWS },
36 36 TYPE_FOLLOWS => { :name => :label_follows, :sym_name => :label_precedes, :order => 7, :sym => TYPE_PRECEDES, :reverse => TYPE_PRECEDES }
37 37 }.freeze
38 38
39 39 validates_presence_of :issue_from, :issue_to, :relation_type
40 40 validates_inclusion_of :relation_type, :in => TYPES.keys
41 41 validates_numericality_of :delay, :allow_nil => true
42 42 validates_uniqueness_of :issue_to_id, :scope => :issue_from_id
43 43
44 44 validate :validate_issue_relation
45 45
46 46 attr_protected :issue_from_id, :issue_to_id
47 47
48 48 before_save :handle_issue_order
49 49
50 50 def visible?(user=User.current)
51 51 (issue_from.nil? || issue_from.visible?(user)) && (issue_to.nil? || issue_to.visible?(user))
52 52 end
53 53
54 54 def deletable?(user=User.current)
55 55 visible?(user) &&
56 56 ((issue_from.nil? || user.allowed_to?(:manage_issue_relations, issue_from.project)) ||
57 57 (issue_to.nil? || user.allowed_to?(:manage_issue_relations, issue_to.project)))
58 58 end
59 59
60 def after_initialize
60 def initialize(attributes=nil, *args)
61 super
61 62 if new_record?
62 63 if relation_type.blank?
63 64 self.relation_type = IssueRelation::TYPE_RELATES
64 65 end
65 66 end
66 67 end
67 68
68 69 def validate_issue_relation
69 70 if issue_from && issue_to
70 71 errors.add :issue_to_id, :invalid if issue_from_id == issue_to_id
71 72 errors.add :issue_to_id, :not_same_project unless issue_from.project_id == issue_to.project_id || Setting.cross_project_issue_relations?
72 73 #detect circular dependencies depending wether the relation should be reversed
73 74 if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
74 75 errors.add :base, :circular_dependency if issue_from.all_dependent_issues.include? issue_to
75 76 else
76 77 errors.add :base, :circular_dependency if issue_to.all_dependent_issues.include? issue_from
77 78 end
78 79 errors.add :base, :cant_link_an_issue_with_a_descendant if issue_from.is_descendant_of?(issue_to) || issue_from.is_ancestor_of?(issue_to)
79 80 end
80 81 end
81 82
82 83 def other_issue(issue)
83 84 (self.issue_from_id == issue.id) ? issue_to : issue_from
84 85 end
85 86
86 87 # Returns the relation type for +issue+
87 88 def relation_type_for(issue)
88 89 if TYPES[relation_type]
89 90 if self.issue_from_id == issue.id
90 91 relation_type
91 92 else
92 93 TYPES[relation_type][:sym]
93 94 end
94 95 end
95 96 end
96 97
97 98 def label_for(issue)
98 99 TYPES[relation_type] ? TYPES[relation_type][(self.issue_from_id == issue.id) ? :name : :sym_name] : :unknow
99 100 end
100 101
101 102 def handle_issue_order
102 103 reverse_if_needed
103 104
104 105 if TYPE_PRECEDES == relation_type
105 106 self.delay ||= 0
106 107 else
107 108 self.delay = nil
108 109 end
109 110 set_issue_to_dates
110 111 end
111 112
112 113 def set_issue_to_dates
113 114 soonest_start = self.successor_soonest_start
114 115 if soonest_start && issue_to
115 116 issue_to.reschedule_after(soonest_start)
116 117 end
117 118 end
118 119
119 120 def successor_soonest_start
120 121 if (TYPE_PRECEDES == self.relation_type) && delay && issue_from && (issue_from.start_date || issue_from.due_date)
121 122 (issue_from.due_date || issue_from.start_date) + 1 + delay
122 123 end
123 124 end
124 125
125 126 def <=>(relation)
126 127 TYPES[self.relation_type][:order] <=> TYPES[relation.relation_type][:order]
127 128 end
128 129
129 130 private
130 131
131 132 # Reverses the relation if needed so that it gets stored in the proper way
132 133 # Should not be reversed before validation so that it can be displayed back
133 134 # as entered on new relation form
134 135 def reverse_if_needed
135 136 if TYPES.has_key?(relation_type) && TYPES[relation_type][:reverse]
136 137 issue_tmp = issue_to
137 138 self.issue_to = issue_from
138 139 self.issue_from = issue_tmp
139 140 self.relation_type = TYPES[relation_type][:reverse]
140 141 end
141 142 end
142 143 end
@@ -1,819 +1,815
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 QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.default_order = options[:default_order]
30 30 @caption_key = options[:caption] || "field_#{name}"
31 31 end
32 32
33 33 def caption
34 34 l(@caption_key)
35 35 end
36 36
37 37 # Returns true if the column is sortable, otherwise false
38 38 def sortable?
39 39 !@sortable.nil?
40 40 end
41 41
42 42 def sortable
43 43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 44 end
45 45
46 46 def value(issue)
47 47 issue.send name
48 48 end
49 49
50 50 def css_classes
51 51 name
52 52 end
53 53 end
54 54
55 55 class QueryCustomFieldColumn < QueryColumn
56 56
57 57 def initialize(custom_field)
58 58 self.name = "cf_#{custom_field.id}".to_sym
59 59 self.sortable = custom_field.order_statement || false
60 60 if %w(list date bool int).include?(custom_field.field_format)
61 61 self.groupable = custom_field.order_statement
62 62 end
63 63 self.groupable ||= false
64 64 @cf = custom_field
65 65 end
66 66
67 67 def caption
68 68 @cf.name
69 69 end
70 70
71 71 def custom_field
72 72 @cf
73 73 end
74 74
75 75 def value(issue)
76 76 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
77 77 cv && @cf.cast_value(cv.value)
78 78 end
79 79
80 80 def css_classes
81 81 @css_classes ||= "#{name} #{@cf.field_format}"
82 82 end
83 83 end
84 84
85 85 class Query < ActiveRecord::Base
86 86 class StatementInvalid < ::ActiveRecord::StatementInvalid
87 87 end
88 88
89 89 belongs_to :project
90 90 belongs_to :user
91 91 serialize :filters
92 92 serialize :column_names
93 93 serialize :sort_criteria, Array
94 94
95 95 attr_protected :project_id, :user_id
96 96
97 97 validates_presence_of :name, :on => :save
98 98 validates_length_of :name, :maximum => 255
99 99 validate :validate_query_filters
100 100
101 101 @@operators = { "=" => :label_equals,
102 102 "!" => :label_not_equals,
103 103 "o" => :label_open_issues,
104 104 "c" => :label_closed_issues,
105 105 "!*" => :label_none,
106 106 "*" => :label_all,
107 107 ">=" => :label_greater_or_equal,
108 108 "<=" => :label_less_or_equal,
109 109 "><" => :label_between,
110 110 "<t+" => :label_in_less_than,
111 111 ">t+" => :label_in_more_than,
112 112 "t+" => :label_in,
113 113 "t" => :label_today,
114 114 "w" => :label_this_week,
115 115 ">t-" => :label_less_than_ago,
116 116 "<t-" => :label_more_than_ago,
117 117 "t-" => :label_ago,
118 118 "~" => :label_contains,
119 119 "!~" => :label_not_contains }
120 120
121 121 cattr_reader :operators
122 122
123 123 @@operators_by_filter_type = { :list => [ "=", "!" ],
124 124 :list_status => [ "o", "=", "!", "c", "*" ],
125 125 :list_optional => [ "=", "!", "!*", "*" ],
126 126 :list_subprojects => [ "*", "!*", "=" ],
127 127 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-", "!*", "*" ],
128 128 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "t-", "t", "w", "!*", "*" ],
129 129 :string => [ "=", "~", "!", "!~" ],
130 130 :text => [ "~", "!~" ],
131 131 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
132 132 :float => [ "=", ">=", "<=", "><", "!*", "*" ] }
133 133
134 134 cattr_reader :operators_by_filter_type
135 135
136 136 @@available_columns = [
137 137 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
138 138 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
139 139 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
140 140 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
141 141 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
142 142 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
143 143 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
144 144 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
145 145 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
146 146 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
147 147 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
148 148 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
149 149 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
150 150 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
151 151 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
152 152 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
153 153 ]
154 154 cattr_reader :available_columns
155 155
156 156 named_scope :visible, lambda {|*args|
157 157 user = args.shift || User.current
158 158 base = Project.allowed_to_condition(user, :view_issues, *args)
159 159 user_id = user.logged? ? user.id : 0
160 160 {
161 161 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
162 162 :include => :project
163 163 }
164 164 }
165 165
166 166 def initialize(attributes=nil, *args)
167 167 super attributes
168 168 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
169 end
170
171 def after_initialize
172 # Store the fact that project is nil (used in #editable_by?)
173 169 @is_for_all = project.nil?
174 170 end
175 171
176 172 def validate_query_filters
177 173 filters.each_key do |field|
178 174 if values_for(field)
179 175 case type_for(field)
180 176 when :integer
181 177 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
182 178 when :float
183 179 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+(\.\d*)?$/) }
184 180 when :date, :date_past
185 181 case operator_for(field)
186 182 when "=", ">=", "<=", "><"
187 183 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
188 184 when ">t-", "<t-", "t-"
189 185 errors.add(label_for(field), :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
190 186 end
191 187 end
192 188 end
193 189
194 190 errors.add label_for(field), :blank unless
195 191 # filter requires one or more values
196 192 (values_for(field) and !values_for(field).first.blank?) or
197 193 # filter doesn't require any value
198 194 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
199 195 end if filters
200 196 end
201 197
202 198 # Returns true if the query is visible to +user+ or the current user.
203 199 def visible?(user=User.current)
204 200 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
205 201 end
206 202
207 203 def editable_by?(user)
208 204 return false unless user
209 205 # Admin can edit them all and regular users can edit their private queries
210 206 return true if user.admin? || (!is_public && self.user_id == user.id)
211 207 # Members can not edit public queries that are for all project (only admin is allowed to)
212 208 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
213 209 end
214 210
215 211 def available_filters
216 212 return @available_filters if @available_filters
217 213
218 214 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
219 215
220 216 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
221 217 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
222 218 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
223 219 "subject" => { :type => :text, :order => 8 },
224 220 "created_on" => { :type => :date_past, :order => 9 },
225 221 "updated_on" => { :type => :date_past, :order => 10 },
226 222 "start_date" => { :type => :date, :order => 11 },
227 223 "due_date" => { :type => :date, :order => 12 },
228 224 "estimated_hours" => { :type => :float, :order => 13 },
229 225 "done_ratio" => { :type => :integer, :order => 14 }}
230 226
231 227 principals = []
232 228 if project
233 229 principals += project.principals.sort
234 230 else
235 231 all_projects = Project.visible.all
236 232 if all_projects.any?
237 233 # members of visible projects
238 234 principals += Principal.active.find(:all, :conditions => ["#{User.table_name}.id IN (SELECT DISTINCT user_id FROM members WHERE project_id IN (?))", all_projects.collect(&:id)]).sort
239 235
240 236 # project filter
241 237 project_values = []
242 238 Project.project_tree(all_projects) do |p, level|
243 239 prefix = (level > 0 ? ('--' * level + ' ') : '')
244 240 project_values << ["#{prefix}#{p.name}", p.id.to_s]
245 241 end
246 242 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
247 243 end
248 244 end
249 245 users = principals.select {|p| p.is_a?(User)}
250 246
251 247 assigned_to_values = []
252 248 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
253 249 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
254 250 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => assigned_to_values } unless assigned_to_values.empty?
255 251
256 252 author_values = []
257 253 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
258 254 author_values += users.collect{|s| [s.name, s.id.to_s] }
259 255 @available_filters["author_id"] = { :type => :list, :order => 5, :values => author_values } unless author_values.empty?
260 256
261 257 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
262 258 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
263 259
264 260 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
265 261 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
266 262
267 263 if User.current.logged?
268 264 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
269 265 end
270 266
271 267 if project
272 268 # project specific filters
273 269 categories = project.issue_categories.all
274 270 unless categories.empty?
275 271 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => categories.collect{|s| [s.name, s.id.to_s] } }
276 272 end
277 273 versions = project.shared_versions.all
278 274 unless versions.empty?
279 275 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
280 276 end
281 277 unless project.leaf?
282 278 subprojects = project.descendants.visible.all
283 279 unless subprojects.empty?
284 280 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => subprojects.collect{|s| [s.name, s.id.to_s] } }
285 281 end
286 282 end
287 283 add_custom_fields_filters(project.all_issue_custom_fields)
288 284 else
289 285 # global filters for cross project issue list
290 286 system_shared_versions = Version.visible.find_all_by_sharing('system')
291 287 unless system_shared_versions.empty?
292 288 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => system_shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
293 289 end
294 290 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
295 291 end
296 292 @available_filters
297 293 end
298 294
299 295 def add_filter(field, operator, values)
300 296 # values must be an array
301 297 return unless values.nil? || values.is_a?(Array)
302 298 # check if field is defined as an available filter
303 299 if available_filters.has_key? field
304 300 filter_options = available_filters[field]
305 301 # check if operator is allowed for that filter
306 302 #if @@operators_by_filter_type[filter_options[:type]].include? operator
307 303 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
308 304 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
309 305 #end
310 306 filters[field] = {:operator => operator, :values => (values || [''])}
311 307 end
312 308 end
313 309
314 310 def add_short_filter(field, expression)
315 311 return unless expression && available_filters.has_key?(field)
316 312 field_type = available_filters[field][:type]
317 313 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
318 314 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
319 315 add_filter field, operator, $1.present? ? $1.split('|') : ['']
320 316 end || add_filter(field, '=', expression.split('|'))
321 317 end
322 318
323 319 # Add multiple filters using +add_filter+
324 320 def add_filters(fields, operators, values)
325 321 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
326 322 fields.each do |field|
327 323 add_filter(field, operators[field], values && values[field])
328 324 end
329 325 end
330 326 end
331 327
332 328 def has_filter?(field)
333 329 filters and filters[field]
334 330 end
335 331
336 332 def type_for(field)
337 333 available_filters[field][:type] if available_filters.has_key?(field)
338 334 end
339 335
340 336 def operator_for(field)
341 337 has_filter?(field) ? filters[field][:operator] : nil
342 338 end
343 339
344 340 def values_for(field)
345 341 has_filter?(field) ? filters[field][:values] : nil
346 342 end
347 343
348 344 def value_for(field, index=0)
349 345 (values_for(field) || [])[index]
350 346 end
351 347
352 348 def label_for(field)
353 349 label = available_filters[field][:name] if available_filters.has_key?(field)
354 350 label ||= field.gsub(/\_id$/, "")
355 351 end
356 352
357 353 def available_columns
358 354 return @available_columns if @available_columns
359 355 @available_columns = ::Query.available_columns.dup
360 356 @available_columns += (project ?
361 357 project.all_issue_custom_fields :
362 358 IssueCustomField.find(:all)
363 359 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
364 360
365 361 if User.current.allowed_to?(:view_time_entries, project, :global => true)
366 362 index = nil
367 363 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
368 364 index = (index ? index + 1 : -1)
369 365 # insert the column after estimated_hours or at the end
370 366 @available_columns.insert index, QueryColumn.new(:spent_hours,
371 367 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
372 368 :default_order => 'desc',
373 369 :caption => :label_spent_time
374 370 )
375 371 end
376 372 @available_columns
377 373 end
378 374
379 375 def self.available_columns=(v)
380 376 self.available_columns = (v)
381 377 end
382 378
383 379 def self.add_available_column(column)
384 380 self.available_columns << (column) if column.is_a?(QueryColumn)
385 381 end
386 382
387 383 # Returns an array of columns that can be used to group the results
388 384 def groupable_columns
389 385 available_columns.select {|c| c.groupable}
390 386 end
391 387
392 388 # Returns a Hash of columns and the key for sorting
393 389 def sortable_columns
394 390 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
395 391 h[column.name.to_s] = column.sortable
396 392 h
397 393 })
398 394 end
399 395
400 396 def columns
401 397 # preserve the column_names order
402 398 (has_default_columns? ? default_columns_names : column_names).collect do |name|
403 399 available_columns.find { |col| col.name == name }
404 400 end.compact
405 401 end
406 402
407 403 def default_columns_names
408 404 @default_columns_names ||= begin
409 405 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
410 406
411 407 project.present? ? default_columns : [:project] | default_columns
412 408 end
413 409 end
414 410
415 411 def column_names=(names)
416 412 if names
417 413 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
418 414 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
419 415 # Set column_names to nil if default columns
420 416 if names == default_columns_names
421 417 names = nil
422 418 end
423 419 end
424 420 write_attribute(:column_names, names)
425 421 end
426 422
427 423 def has_column?(column)
428 424 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
429 425 end
430 426
431 427 def has_default_columns?
432 428 column_names.nil? || column_names.empty?
433 429 end
434 430
435 431 def sort_criteria=(arg)
436 432 c = []
437 433 if arg.is_a?(Hash)
438 434 arg = arg.keys.sort.collect {|k| arg[k]}
439 435 end
440 436 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
441 437 write_attribute(:sort_criteria, c)
442 438 end
443 439
444 440 def sort_criteria
445 441 read_attribute(:sort_criteria) || []
446 442 end
447 443
448 444 def sort_criteria_key(arg)
449 445 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
450 446 end
451 447
452 448 def sort_criteria_order(arg)
453 449 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
454 450 end
455 451
456 452 # Returns the SQL sort order that should be prepended for grouping
457 453 def group_by_sort_order
458 454 if grouped? && (column = group_by_column)
459 455 column.sortable.is_a?(Array) ?
460 456 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
461 457 "#{column.sortable} #{column.default_order}"
462 458 end
463 459 end
464 460
465 461 # Returns true if the query is a grouped query
466 462 def grouped?
467 463 !group_by_column.nil?
468 464 end
469 465
470 466 def group_by_column
471 467 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
472 468 end
473 469
474 470 def group_by_statement
475 471 group_by_column.try(:groupable)
476 472 end
477 473
478 474 def project_statement
479 475 project_clauses = []
480 476 if project && !project.descendants.active.empty?
481 477 ids = [project.id]
482 478 if has_filter?("subproject_id")
483 479 case operator_for("subproject_id")
484 480 when '='
485 481 # include the selected subprojects
486 482 ids += values_for("subproject_id").each(&:to_i)
487 483 when '!*'
488 484 # main project only
489 485 else
490 486 # all subprojects
491 487 ids += project.descendants.collect(&:id)
492 488 end
493 489 elsif Setting.display_subprojects_issues?
494 490 ids += project.descendants.collect(&:id)
495 491 end
496 492 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
497 493 elsif project
498 494 project_clauses << "#{Project.table_name}.id = %d" % project.id
499 495 end
500 496 project_clauses.any? ? project_clauses.join(' AND ') : nil
501 497 end
502 498
503 499 def statement
504 500 # filters clauses
505 501 filters_clauses = []
506 502 filters.each_key do |field|
507 503 next if field == "subproject_id"
508 504 v = values_for(field).clone
509 505 next unless v and !v.empty?
510 506 operator = operator_for(field)
511 507
512 508 # "me" value subsitution
513 509 if %w(assigned_to_id author_id watcher_id).include?(field)
514 510 if v.delete("me")
515 511 if User.current.logged?
516 512 v.push(User.current.id.to_s)
517 513 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
518 514 else
519 515 v.push("0")
520 516 end
521 517 end
522 518 end
523 519
524 520 if field =~ /^cf_(\d+)$/
525 521 # custom field
526 522 filters_clauses << sql_for_custom_field(field, operator, v, $1)
527 523 elsif respond_to?("sql_for_#{field}_field")
528 524 # specific statement
529 525 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
530 526 else
531 527 # regular field
532 528 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
533 529 end
534 530 end if filters and valid?
535 531
536 532 filters_clauses << project_statement
537 533 filters_clauses.reject!(&:blank?)
538 534
539 535 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
540 536 end
541 537
542 538 # Returns the issue count
543 539 def issue_count
544 540 Issue.visible.count(:include => [:status, :project], :conditions => statement)
545 541 rescue ::ActiveRecord::StatementInvalid => e
546 542 raise StatementInvalid.new(e.message)
547 543 end
548 544
549 545 # Returns the issue count by group or nil if query is not grouped
550 546 def issue_count_by_group
551 547 r = nil
552 548 if grouped?
553 549 begin
554 550 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
555 551 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
556 552 rescue ActiveRecord::RecordNotFound
557 553 r = {nil => issue_count}
558 554 end
559 555 c = group_by_column
560 556 if c.is_a?(QueryCustomFieldColumn)
561 557 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
562 558 end
563 559 end
564 560 r
565 561 rescue ::ActiveRecord::StatementInvalid => e
566 562 raise StatementInvalid.new(e.message)
567 563 end
568 564
569 565 # Returns the issues
570 566 # Valid options are :order, :offset, :limit, :include, :conditions
571 567 def issues(options={})
572 568 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
573 569 order_option = nil if order_option.blank?
574 570
575 571 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
576 572
577 573 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
578 574 :conditions => statement,
579 575 :order => order_option,
580 576 :joins => joins,
581 577 :limit => options[:limit],
582 578 :offset => options[:offset]
583 579
584 580 if has_column?(:spent_hours)
585 581 Issue.load_visible_spent_hours(issues)
586 582 end
587 583 issues
588 584 rescue ::ActiveRecord::StatementInvalid => e
589 585 raise StatementInvalid.new(e.message)
590 586 end
591 587
592 588 # Returns the journals
593 589 # Valid options are :order, :offset, :limit
594 590 def journals(options={})
595 591 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
596 592 :conditions => statement,
597 593 :order => options[:order],
598 594 :limit => options[:limit],
599 595 :offset => options[:offset]
600 596 rescue ::ActiveRecord::StatementInvalid => e
601 597 raise StatementInvalid.new(e.message)
602 598 end
603 599
604 600 # Returns the versions
605 601 # Valid options are :conditions
606 602 def versions(options={})
607 603 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
608 604 rescue ::ActiveRecord::StatementInvalid => e
609 605 raise StatementInvalid.new(e.message)
610 606 end
611 607
612 608 def sql_for_watcher_id_field(field, operator, value)
613 609 db_table = Watcher.table_name
614 610 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
615 611 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
616 612 end
617 613
618 614 def sql_for_member_of_group_field(field, operator, value)
619 615 if operator == '*' # Any group
620 616 groups = Group.all
621 617 operator = '=' # Override the operator since we want to find by assigned_to
622 618 elsif operator == "!*"
623 619 groups = Group.all
624 620 operator = '!' # Override the operator since we want to find by assigned_to
625 621 else
626 622 groups = Group.find_all_by_id(value)
627 623 end
628 624 groups ||= []
629 625
630 626 members_of_groups = groups.inject([]) {|user_ids, group|
631 627 if group && group.user_ids.present?
632 628 user_ids << group.user_ids
633 629 end
634 630 user_ids.flatten.uniq.compact
635 631 }.sort.collect(&:to_s)
636 632
637 633 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
638 634 end
639 635
640 636 def sql_for_assigned_to_role_field(field, operator, value)
641 637 case operator
642 638 when "*", "!*" # Member / Not member
643 639 sw = operator == "!*" ? 'NOT' : ''
644 640 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
645 641 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
646 642 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
647 643 when "=", "!"
648 644 role_cond = value.any? ?
649 645 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
650 646 "1=0"
651 647
652 648 sw = operator == "!" ? 'NOT' : ''
653 649 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
654 650 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
655 651 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
656 652 end
657 653 end
658 654
659 655 private
660 656
661 657 def sql_for_custom_field(field, operator, value, custom_field_id)
662 658 db_table = CustomValue.table_name
663 659 db_field = 'value'
664 660 "#{Issue.table_name}.id IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
665 661 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
666 662 end
667 663
668 664 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
669 665 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
670 666 sql = ''
671 667 case operator
672 668 when "="
673 669 if value.any?
674 670 case type_for(field)
675 671 when :date, :date_past
676 672 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
677 673 when :integer
678 674 if is_custom_filter
679 675 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
680 676 else
681 677 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
682 678 end
683 679 when :float
684 680 if is_custom_filter
685 681 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
686 682 else
687 683 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
688 684 end
689 685 else
690 686 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
691 687 end
692 688 else
693 689 # IN an empty set
694 690 sql = "1=0"
695 691 end
696 692 when "!"
697 693 if value.any?
698 694 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
699 695 else
700 696 # NOT IN an empty set
701 697 sql = "1=1"
702 698 end
703 699 when "!*"
704 700 sql = "#{db_table}.#{db_field} IS NULL"
705 701 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
706 702 when "*"
707 703 sql = "#{db_table}.#{db_field} IS NOT NULL"
708 704 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
709 705 when ">="
710 706 if [:date, :date_past].include?(type_for(field))
711 707 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
712 708 else
713 709 if is_custom_filter
714 710 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
715 711 else
716 712 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
717 713 end
718 714 end
719 715 when "<="
720 716 if [:date, :date_past].include?(type_for(field))
721 717 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
722 718 else
723 719 if is_custom_filter
724 720 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
725 721 else
726 722 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
727 723 end
728 724 end
729 725 when "><"
730 726 if [:date, :date_past].include?(type_for(field))
731 727 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
732 728 else
733 729 if is_custom_filter
734 730 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
735 731 else
736 732 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
737 733 end
738 734 end
739 735 when "o"
740 736 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
741 737 when "c"
742 738 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
743 739 when ">t-"
744 740 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
745 741 when "<t-"
746 742 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
747 743 when "t-"
748 744 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
749 745 when ">t+"
750 746 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
751 747 when "<t+"
752 748 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
753 749 when "t+"
754 750 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
755 751 when "t"
756 752 sql = relative_date_clause(db_table, db_field, 0, 0)
757 753 when "w"
758 754 first_day_of_week = l(:general_first_day_of_week).to_i
759 755 day_of_week = Date.today.cwday
760 756 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
761 757 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
762 758 when "~"
763 759 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
764 760 when "!~"
765 761 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
766 762 else
767 763 raise "Unknown query operator #{operator}"
768 764 end
769 765
770 766 return sql
771 767 end
772 768
773 769 def add_custom_fields_filters(custom_fields)
774 770 @available_filters ||= {}
775 771
776 772 custom_fields.select(&:is_filter?).each do |field|
777 773 case field.field_format
778 774 when "text"
779 775 options = { :type => :text, :order => 20 }
780 776 when "list"
781 777 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
782 778 when "date"
783 779 options = { :type => :date, :order => 20 }
784 780 when "bool"
785 781 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
786 782 when "int"
787 783 options = { :type => :integer, :order => 20 }
788 784 when "float"
789 785 options = { :type => :float, :order => 20 }
790 786 when "user", "version"
791 787 next unless project
792 788 options = { :type => :list_optional, :values => field.possible_values_options(project), :order => 20}
793 789 else
794 790 options = { :type => :string, :order => 20 }
795 791 end
796 792 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
797 793 end
798 794 end
799 795
800 796 # Returns a SQL clause for a date or datetime field.
801 797 def date_clause(table, field, from, to)
802 798 s = []
803 799 if from
804 800 from_yesterday = from - 1
805 801 from_yesterday_utc = Time.gm(from_yesterday.year, from_yesterday.month, from_yesterday.day)
806 802 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_utc.end_of_day)])
807 803 end
808 804 if to
809 805 to_utc = Time.gm(to.year, to.month, to.day)
810 806 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_utc.end_of_day)])
811 807 end
812 808 s.join(' AND ')
813 809 end
814 810
815 811 # Returns a SQL clause for a date or datetime field using relative dates.
816 812 def relative_date_clause(table, field, days_from, days_to)
817 813 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
818 814 end
819 815 end
@@ -1,107 +1,108
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 before_validation :set_project_if_nil
42 42 validate :validate_time_entry
43 43
44 44 named_scope :visible, lambda {|*args| {
45 45 :include => :project,
46 46 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
47 47 }}
48 48 named_scope :on_issue, lambda {|issue| {
49 49 :include => :issue,
50 50 :conditions => "#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}"
51 51 }}
52 52 named_scope :on_project, lambda {|project, include_subprojects| {
53 53 :include => :project,
54 54 :conditions => project.project_condition(include_subprojects)
55 55 }}
56 56 named_scope :spent_between, lambda {|from, to|
57 57 if from && to
58 58 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
59 59 elsif from
60 60 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
61 61 elsif to
62 62 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
63 63 else
64 64 {}
65 65 end
66 66 }
67 67
68 def after_initialize
68 def initialize(attributes=nil, *args)
69 super
69 70 if new_record? && self.activity.nil?
70 71 if default_activity = TimeEntryActivity.default
71 72 self.activity_id = default_activity.id
72 73 end
73 74 self.hours = nil if hours == 0
74 75 end
75 76 end
76 77
77 78 def set_project_if_nil
78 79 self.project = issue.project if issue && project.nil?
79 80 end
80 81
81 82 def validate_time_entry
82 83 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
83 84 errors.add :project_id, :invalid if project.nil?
84 85 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
85 86 end
86 87
87 88 def hours=(h)
88 89 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
89 90 end
90 91
91 92 # tyear, tmonth, tweek assigned where setting spent_on attributes
92 93 # these attributes make time aggregations easier
93 94 def spent_on=(date)
94 95 super
95 96 if spent_on.is_a?(Time)
96 97 self.spent_on = spent_on.to_date
97 98 end
98 99 self.tyear = spent_on ? spent_on.year : nil
99 100 self.tmonth = spent_on ? spent_on.month : nil
100 101 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
101 102 end
102 103
103 104 # Returns true if the time entry can be edited by usr, otherwise false
104 105 def editable_by?(usr)
105 106 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
106 107 end
107 108 end
@@ -1,225 +1,226
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 'diff'
19 19 require 'enumerator'
20 20
21 21 class WikiPage < ActiveRecord::Base
22 22 belongs_to :wiki
23 23 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
24 24 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
25 25 acts_as_tree :dependent => :nullify, :order => 'title'
26 26
27 27 acts_as_watchable
28 28 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
29 29 :description => :text,
30 30 :datetime => :created_on,
31 31 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
32 32
33 33 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
34 34 :include => [{:wiki => :project}, :content],
35 35 :permission => :view_wiki_pages,
36 36 :project_key => "#{Wiki.table_name}.project_id"
37 37
38 38 attr_accessor :redirect_existing_links
39 39
40 40 validates_presence_of :title
41 41 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
42 42 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
43 43 validates_associated :content
44 44
45 45 validate :validate_parent_title
46 46 before_destroy :remove_redirects
47 47 before_save :handle_redirects
48 48
49 49 # eager load information about last updates, without loading text
50 50 named_scope :with_updated_on, {
51 51 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on",
52 52 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
53 53 }
54 54
55 55 # Wiki pages that are protected by default
56 56 DEFAULT_PROTECTED_PAGES = %w(sidebar)
57 57
58 def after_initialize
58 def initialize(attributes=nil, *args)
59 super
59 60 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
60 61 self.protected = true
61 62 end
62 63 end
63 64
64 65 def visible?(user=User.current)
65 66 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
66 67 end
67 68
68 69 def title=(value)
69 70 value = Wiki.titleize(value)
70 71 @previous_title = read_attribute(:title) if @previous_title.blank?
71 72 write_attribute(:title, value)
72 73 end
73 74
74 75 def handle_redirects
75 76 self.title = Wiki.titleize(title)
76 77 # Manage redirects if the title has changed
77 78 if !@previous_title.blank? && (@previous_title != title) && !new_record?
78 79 # Update redirects that point to the old title
79 80 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
80 81 r.redirects_to = title
81 82 r.title == r.redirects_to ? r.destroy : r.save
82 83 end
83 84 # Remove redirects for the new title
84 85 wiki.redirects.find_all_by_title(title).each(&:destroy)
85 86 # Create a redirect to the new title
86 87 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
87 88 @previous_title = nil
88 89 end
89 90 end
90 91
91 92 def remove_redirects
92 93 # Remove redirects to this page
93 94 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
94 95 end
95 96
96 97 def pretty_title
97 98 WikiPage.pretty_title(title)
98 99 end
99 100
100 101 def content_for_version(version=nil)
101 102 result = content.versions.find_by_version(version.to_i) if version
102 103 result ||= content
103 104 result
104 105 end
105 106
106 107 def diff(version_to=nil, version_from=nil)
107 108 version_to = version_to ? version_to.to_i : self.content.version
108 109 version_from = version_from ? version_from.to_i : version_to - 1
109 110 version_to, version_from = version_from, version_to unless version_from < version_to
110 111
111 112 content_to = content.versions.find_by_version(version_to)
112 113 content_from = content.versions.find_by_version(version_from)
113 114
114 115 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
115 116 end
116 117
117 118 def annotate(version=nil)
118 119 version = version ? version.to_i : self.content.version
119 120 c = content.versions.find_by_version(version)
120 121 c ? WikiAnnotate.new(c) : nil
121 122 end
122 123
123 124 def self.pretty_title(str)
124 125 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
125 126 end
126 127
127 128 def project
128 129 wiki.project
129 130 end
130 131
131 132 def text
132 133 content.text if content
133 134 end
134 135
135 136 def updated_on
136 137 unless @updated_on
137 138 if time = read_attribute(:updated_on)
138 139 # content updated_on was eager loaded with the page
139 140 @updated_on = Time.parse(time) rescue nil
140 141 else
141 142 @updated_on = content && content.updated_on
142 143 end
143 144 end
144 145 @updated_on
145 146 end
146 147
147 148 # Returns true if usr is allowed to edit the page, otherwise false
148 149 def editable_by?(usr)
149 150 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
150 151 end
151 152
152 153 def attachments_deletable?(usr=User.current)
153 154 editable_by?(usr) && super(usr)
154 155 end
155 156
156 157 def parent_title
157 158 @parent_title || (self.parent && self.parent.pretty_title)
158 159 end
159 160
160 161 def parent_title=(t)
161 162 @parent_title = t
162 163 parent_page = t.blank? ? nil : self.wiki.find_page(t)
163 164 self.parent = parent_page
164 165 end
165 166
166 167 protected
167 168
168 169 def validate_parent_title
169 170 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
170 171 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
171 172 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
172 173 end
173 174 end
174 175
175 176 class WikiDiff < Redmine::Helpers::Diff
176 177 attr_reader :content_to, :content_from
177 178
178 179 def initialize(content_to, content_from)
179 180 @content_to = content_to
180 181 @content_from = content_from
181 182 super(content_to.text, content_from.text)
182 183 end
183 184 end
184 185
185 186 class WikiAnnotate
186 187 attr_reader :lines, :content
187 188
188 189 def initialize(content)
189 190 @content = content
190 191 current = content
191 192 current_lines = current.text.split(/\r?\n/)
192 193 @lines = current_lines.collect {|t| [nil, nil, t]}
193 194 positions = []
194 195 current_lines.size.times {|i| positions << i}
195 196 while (current.previous)
196 197 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
197 198 d.each_slice(3) do |s|
198 199 sign, line = s[0], s[1]
199 200 if sign == '+' && positions[line] && positions[line] != -1
200 201 if @lines[positions[line]][0].nil?
201 202 @lines[positions[line]][0] = current.version
202 203 @lines[positions[line]][1] = current.author
203 204 end
204 205 end
205 206 end
206 207 d.each_slice(3) do |s|
207 208 sign, line = s[0], s[1]
208 209 if sign == '-'
209 210 positions.insert(line, -1)
210 211 else
211 212 positions[line] = nil
212 213 end
213 214 end
214 215 positions.compact!
215 216 # Stop if every line is annotated
216 217 break unless @lines.detect { |line| line[0].nil? }
217 218 current = current.previous
218 219 end
219 220 @lines.each { |line|
220 221 line[0] ||= current.version
221 222 # if the last known version is > 1 (eg. history was cleared), we don't know the author
222 223 line[1] ||= current.author if current.version == 1
223 224 }
224 225 end
225 226 end
General Comments 0
You need to be logged in to leave comments. Login now