##// END OF EJS Templates
model: replace Rails2 "named_scope" to Rails3 "scope"...
Toshi MARUYAMA -
r9355:d0d01d4e704b
parent child
Show More
@@ -1,56 +1,56
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Board < ActiveRecord::Base
18 class Board < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 belongs_to :project
20 belongs_to :project
21 has_many :topics, :class_name => 'Message', :conditions => "#{Message.table_name}.parent_id IS NULL", :order => "#{Message.table_name}.created_on DESC"
21 has_many :topics, :class_name => 'Message', :conditions => "#{Message.table_name}.parent_id IS NULL", :order => "#{Message.table_name}.created_on DESC"
22 has_many :messages, :dependent => :destroy, :order => "#{Message.table_name}.created_on DESC"
22 has_many :messages, :dependent => :destroy, :order => "#{Message.table_name}.created_on DESC"
23 belongs_to :last_message, :class_name => 'Message', :foreign_key => :last_message_id
23 belongs_to :last_message, :class_name => 'Message', :foreign_key => :last_message_id
24 acts_as_list :scope => :project_id
24 acts_as_list :scope => :project_id
25 acts_as_watchable
25 acts_as_watchable
26
26
27 validates_presence_of :name, :description
27 validates_presence_of :name, :description
28 validates_length_of :name, :maximum => 30
28 validates_length_of :name, :maximum => 30
29 validates_length_of :description, :maximum => 255
29 validates_length_of :description, :maximum => 255
30
30
31 named_scope :visible, lambda {|*args| { :include => :project,
31 scope :visible, lambda {|*args| { :include => :project,
32 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
32 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
33
33
34 safe_attributes 'name', 'description', 'move_to'
34 safe_attributes 'name', 'description', 'move_to'
35
35
36 def visible?(user=User.current)
36 def visible?(user=User.current)
37 !user.nil? && user.allowed_to?(:view_messages, project)
37 !user.nil? && user.allowed_to?(:view_messages, project)
38 end
38 end
39
39
40 def to_s
40 def to_s
41 name
41 name
42 end
42 end
43
43
44 def reset_counters!
44 def reset_counters!
45 self.class.reset_counters!(id)
45 self.class.reset_counters!(id)
46 end
46 end
47
47
48 # Updates topics_count, messages_count and last_message_id attributes for +board_id+
48 # Updates topics_count, messages_count and last_message_id attributes for +board_id+
49 def self.reset_counters!(board_id)
49 def self.reset_counters!(board_id)
50 board_id = board_id.to_i
50 board_id = board_id.to_i
51 update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
51 update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
52 " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
52 " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
53 " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})",
53 " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})",
54 ["id = ?", board_id])
54 ["id = ?", board_id])
55 end
55 end
56 end
56 end
@@ -1,285 +1,286
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'iconv'
18 require 'iconv'
19
19
20 class Changeset < ActiveRecord::Base
20 class Changeset < ActiveRecord::Base
21 belongs_to :repository
21 belongs_to :repository
22 belongs_to :user
22 belongs_to :user
23 has_many :changes, :dependent => :delete_all
23 has_many :changes, :dependent => :delete_all
24 has_and_belongs_to_many :issues
24 has_and_belongs_to_many :issues
25 has_and_belongs_to_many :parents,
25 has_and_belongs_to_many :parents,
26 :class_name => "Changeset",
26 :class_name => "Changeset",
27 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
27 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
28 :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
28 :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
29 has_and_belongs_to_many :children,
29 has_and_belongs_to_many :children,
30 :class_name => "Changeset",
30 :class_name => "Changeset",
31 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
31 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
32 :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
32 :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
33
33
34 acts_as_event :title => Proc.new {|o| o.title},
34 acts_as_event :title => Proc.new {|o| o.title},
35 :description => :long_comments,
35 :description => :long_comments,
36 :datetime => :committed_on,
36 :datetime => :committed_on,
37 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
37 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
38
38
39 acts_as_searchable :columns => 'comments',
39 acts_as_searchable :columns => 'comments',
40 :include => {:repository => :project},
40 :include => {:repository => :project},
41 :project_key => "#{Repository.table_name}.project_id",
41 :project_key => "#{Repository.table_name}.project_id",
42 :date_column => 'committed_on'
42 :date_column => 'committed_on'
43
43
44 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
44 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
45 :author_key => :user_id,
45 :author_key => :user_id,
46 :find_options => {:include => [:user, {:repository => :project}]}
46 :find_options => {:include => [:user, {:repository => :project}]}
47
47
48 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
48 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
49 validates_uniqueness_of :revision, :scope => :repository_id
49 validates_uniqueness_of :revision, :scope => :repository_id
50 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
50 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
51
51
52 named_scope :visible, lambda {|*args| { :include => {:repository => :project},
52 scope :visible,
53 lambda {|*args| { :include => {:repository => :project},
53 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
54 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
54
55
55 after_create :scan_for_issues
56 after_create :scan_for_issues
56 before_create :before_create_cs
57 before_create :before_create_cs
57
58
58 def revision=(r)
59 def revision=(r)
59 write_attribute :revision, (r.nil? ? nil : r.to_s)
60 write_attribute :revision, (r.nil? ? nil : r.to_s)
60 end
61 end
61
62
62 # Returns the identifier of this changeset; depending on repository backends
63 # Returns the identifier of this changeset; depending on repository backends
63 def identifier
64 def identifier
64 if repository.class.respond_to? :changeset_identifier
65 if repository.class.respond_to? :changeset_identifier
65 repository.class.changeset_identifier self
66 repository.class.changeset_identifier self
66 else
67 else
67 revision.to_s
68 revision.to_s
68 end
69 end
69 end
70 end
70
71
71 def committed_on=(date)
72 def committed_on=(date)
72 self.commit_date = date
73 self.commit_date = date
73 super
74 super
74 end
75 end
75
76
76 # Returns the readable identifier
77 # Returns the readable identifier
77 def format_identifier
78 def format_identifier
78 if repository.class.respond_to? :format_changeset_identifier
79 if repository.class.respond_to? :format_changeset_identifier
79 repository.class.format_changeset_identifier self
80 repository.class.format_changeset_identifier self
80 else
81 else
81 identifier
82 identifier
82 end
83 end
83 end
84 end
84
85
85 def project
86 def project
86 repository.project
87 repository.project
87 end
88 end
88
89
89 def author
90 def author
90 user || committer.to_s.split('<').first
91 user || committer.to_s.split('<').first
91 end
92 end
92
93
93 def before_create_cs
94 def before_create_cs
94 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
95 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
95 self.comments = self.class.normalize_comments(
96 self.comments = self.class.normalize_comments(
96 self.comments, repository.repo_log_encoding)
97 self.comments, repository.repo_log_encoding)
97 self.user = repository.find_committer_user(self.committer)
98 self.user = repository.find_committer_user(self.committer)
98 end
99 end
99
100
100 def scan_for_issues
101 def scan_for_issues
101 scan_comment_for_issue_ids
102 scan_comment_for_issue_ids
102 end
103 end
103
104
104 TIMELOG_RE = /
105 TIMELOG_RE = /
105 (
106 (
106 ((\d+)(h|hours?))((\d+)(m|min)?)?
107 ((\d+)(h|hours?))((\d+)(m|min)?)?
107 |
108 |
108 ((\d+)(h|hours?|m|min))
109 ((\d+)(h|hours?|m|min))
109 |
110 |
110 (\d+):(\d+)
111 (\d+):(\d+)
111 |
112 |
112 (\d+([\.,]\d+)?)h?
113 (\d+([\.,]\d+)?)h?
113 )
114 )
114 /x
115 /x
115
116
116 def scan_comment_for_issue_ids
117 def scan_comment_for_issue_ids
117 return if comments.blank?
118 return if comments.blank?
118 # keywords used to reference issues
119 # keywords used to reference issues
119 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
120 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
120 ref_keywords_any = ref_keywords.delete('*')
121 ref_keywords_any = ref_keywords.delete('*')
121 # keywords used to fix issues
122 # keywords used to fix issues
122 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
123 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
123
124
124 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
125 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
125
126
126 referenced_issues = []
127 referenced_issues = []
127
128
128 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
129 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
129 action, refs = match[2], match[3]
130 action, refs = match[2], match[3]
130 next unless action.present? || ref_keywords_any
131 next unless action.present? || ref_keywords_any
131
132
132 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
133 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
133 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
134 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
134 if issue
135 if issue
135 referenced_issues << issue
136 referenced_issues << issue
136 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
137 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
137 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
138 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
138 end
139 end
139 end
140 end
140 end
141 end
141
142
142 referenced_issues.uniq!
143 referenced_issues.uniq!
143 self.issues = referenced_issues unless referenced_issues.empty?
144 self.issues = referenced_issues unless referenced_issues.empty?
144 end
145 end
145
146
146 def short_comments
147 def short_comments
147 @short_comments || split_comments.first
148 @short_comments || split_comments.first
148 end
149 end
149
150
150 def long_comments
151 def long_comments
151 @long_comments || split_comments.last
152 @long_comments || split_comments.last
152 end
153 end
153
154
154 def text_tag(ref_project=nil)
155 def text_tag(ref_project=nil)
155 tag = if scmid?
156 tag = if scmid?
156 "commit:#{scmid}"
157 "commit:#{scmid}"
157 else
158 else
158 "r#{revision}"
159 "r#{revision}"
159 end
160 end
160 if repository && repository.identifier.present?
161 if repository && repository.identifier.present?
161 tag = "#{repository.identifier}|#{tag}"
162 tag = "#{repository.identifier}|#{tag}"
162 end
163 end
163 if ref_project && project && ref_project != project
164 if ref_project && project && ref_project != project
164 tag = "#{project.identifier}:#{tag}"
165 tag = "#{project.identifier}:#{tag}"
165 end
166 end
166 tag
167 tag
167 end
168 end
168
169
169 # Returns the title used for the changeset in the activity/search results
170 # Returns the title used for the changeset in the activity/search results
170 def title
171 def title
171 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
172 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
172 comm = short_comments.blank? ? '' : (': ' + short_comments)
173 comm = short_comments.blank? ? '' : (': ' + short_comments)
173 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
174 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
174 end
175 end
175
176
176 # Returns the previous changeset
177 # Returns the previous changeset
177 def previous
178 def previous
178 @previous ||= Changeset.find(:first,
179 @previous ||= Changeset.find(:first,
179 :conditions => ['id < ? AND repository_id = ?',
180 :conditions => ['id < ? AND repository_id = ?',
180 self.id, self.repository_id],
181 self.id, self.repository_id],
181 :order => 'id DESC')
182 :order => 'id DESC')
182 end
183 end
183
184
184 # Returns the next changeset
185 # Returns the next changeset
185 def next
186 def next
186 @next ||= Changeset.find(:first,
187 @next ||= Changeset.find(:first,
187 :conditions => ['id > ? AND repository_id = ?',
188 :conditions => ['id > ? AND repository_id = ?',
188 self.id, self.repository_id],
189 self.id, self.repository_id],
189 :order => 'id ASC')
190 :order => 'id ASC')
190 end
191 end
191
192
192 # Creates a new Change from it's common parameters
193 # Creates a new Change from it's common parameters
193 def create_change(change)
194 def create_change(change)
194 Change.create(:changeset => self,
195 Change.create(:changeset => self,
195 :action => change[:action],
196 :action => change[:action],
196 :path => change[:path],
197 :path => change[:path],
197 :from_path => change[:from_path],
198 :from_path => change[:from_path],
198 :from_revision => change[:from_revision])
199 :from_revision => change[:from_revision])
199 end
200 end
200
201
201 # Finds an issue that can be referenced by the commit message
202 # Finds an issue that can be referenced by the commit message
202 def find_referenced_issue_by_id(id)
203 def find_referenced_issue_by_id(id)
203 return nil if id.blank?
204 return nil if id.blank?
204 issue = Issue.find_by_id(id.to_i, :include => :project)
205 issue = Issue.find_by_id(id.to_i, :include => :project)
205 if Setting.commit_cross_project_ref?
206 if Setting.commit_cross_project_ref?
206 # all issues can be referenced/fixed
207 # all issues can be referenced/fixed
207 elsif issue
208 elsif issue
208 # issue that belong to the repository project, a subproject or a parent project only
209 # issue that belong to the repository project, a subproject or a parent project only
209 unless issue.project &&
210 unless issue.project &&
210 (project == issue.project || project.is_ancestor_of?(issue.project) ||
211 (project == issue.project || project.is_ancestor_of?(issue.project) ||
211 project.is_descendant_of?(issue.project))
212 project.is_descendant_of?(issue.project))
212 issue = nil
213 issue = nil
213 end
214 end
214 end
215 end
215 issue
216 issue
216 end
217 end
217
218
218 private
219 private
219
220
220 def fix_issue(issue)
221 def fix_issue(issue)
221 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
222 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
222 if status.nil?
223 if status.nil?
223 logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
224 logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
224 return issue
225 return issue
225 end
226 end
226
227
227 # the issue may have been updated by the closure of another one (eg. duplicate)
228 # the issue may have been updated by the closure of another one (eg. duplicate)
228 issue.reload
229 issue.reload
229 # don't change the status is the issue is closed
230 # don't change the status is the issue is closed
230 return if issue.status && issue.status.is_closed?
231 return if issue.status && issue.status.is_closed?
231
232
232 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project)))
233 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project)))
233 issue.status = status
234 issue.status = status
234 unless Setting.commit_fix_done_ratio.blank?
235 unless Setting.commit_fix_done_ratio.blank?
235 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
236 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
236 end
237 end
237 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
238 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
238 { :changeset => self, :issue => issue })
239 { :changeset => self, :issue => issue })
239 unless issue.save
240 unless issue.save
240 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
241 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
241 end
242 end
242 issue
243 issue
243 end
244 end
244
245
245 def log_time(issue, hours)
246 def log_time(issue, hours)
246 time_entry = TimeEntry.new(
247 time_entry = TimeEntry.new(
247 :user => user,
248 :user => user,
248 :hours => hours,
249 :hours => hours,
249 :issue => issue,
250 :issue => issue,
250 :spent_on => commit_date,
251 :spent_on => commit_date,
251 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
252 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
252 :locale => Setting.default_language)
253 :locale => Setting.default_language)
253 )
254 )
254 time_entry.activity = log_time_activity unless log_time_activity.nil?
255 time_entry.activity = log_time_activity unless log_time_activity.nil?
255
256
256 unless time_entry.save
257 unless time_entry.save
257 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
258 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
258 end
259 end
259 time_entry
260 time_entry
260 end
261 end
261
262
262 def log_time_activity
263 def log_time_activity
263 if Setting.commit_logtime_activity_id.to_i > 0
264 if Setting.commit_logtime_activity_id.to_i > 0
264 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
265 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
265 end
266 end
266 end
267 end
267
268
268 def split_comments
269 def split_comments
269 comments =~ /\A(.+?)\r?\n(.*)$/m
270 comments =~ /\A(.+?)\r?\n(.*)$/m
270 @short_comments = $1 || comments
271 @short_comments = $1 || comments
271 @long_comments = $2.to_s.strip
272 @long_comments = $2.to_s.strip
272 return @short_comments, @long_comments
273 return @short_comments, @long_comments
273 end
274 end
274
275
275 public
276 public
276
277
277 # Strips and reencodes a commit log before insertion into the database
278 # Strips and reencodes a commit log before insertion into the database
278 def self.normalize_comments(str, encoding)
279 def self.normalize_comments(str, encoding)
279 Changeset.to_utf8(str.to_s.strip, encoding)
280 Changeset.to_utf8(str.to_s.strip, encoding)
280 end
281 end
281
282
282 def self.to_utf8(str, encoding)
283 def self.to_utf8(str, encoding)
283 Redmine::CodesetUtil.to_utf8(str, encoding)
284 Redmine::CodesetUtil.to_utf8(str, encoding)
284 end
285 end
285 end
286 end
@@ -1,56 +1,56
1 # RedMine - project management software
1 # RedMine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Document < ActiveRecord::Base
18 class Document < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 belongs_to :project
20 belongs_to :project
21 belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
21 belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
22 acts_as_attachable :delete_permission => :manage_documents
22 acts_as_attachable :delete_permission => :manage_documents
23
23
24 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
24 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
25 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
25 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
26 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
26 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
27 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
27 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
28 acts_as_activity_provider :find_options => {:include => :project}
28 acts_as_activity_provider :find_options => {:include => :project}
29
29
30 validates_presence_of :project, :title, :category
30 validates_presence_of :project, :title, :category
31 validates_length_of :title, :maximum => 60
31 validates_length_of :title, :maximum => 60
32
32
33 named_scope :visible, lambda {|*args| { :include => :project,
33 scope :visible, lambda {|*args| { :include => :project,
34 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
34 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
35
35
36 safe_attributes 'category_id', 'title', 'description'
36 safe_attributes 'category_id', 'title', 'description'
37
37
38 def visible?(user=User.current)
38 def visible?(user=User.current)
39 !user.nil? && user.allowed_to?(:view_documents, project)
39 !user.nil? && user.allowed_to?(:view_documents, project)
40 end
40 end
41
41
42 def initialize(attributes=nil, *args)
42 def initialize(attributes=nil, *args)
43 super
43 super
44 if new_record?
44 if new_record?
45 self.category ||= DocumentCategory.default
45 self.category ||= DocumentCategory.default
46 end
46 end
47 end
47 end
48
48
49 def updated_on
49 def updated_on
50 unless @updated_on
50 unless @updated_on
51 a = attachments.last
51 a = attachments.last
52 @updated_on = (a && a.created_on) || created_on
52 @updated_on = (a && a.created_on) || created_on
53 end
53 end
54 @updated_on
54 @updated_on
55 end
55 end
56 end
56 end
@@ -1,140 +1,140
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Enumeration < ActiveRecord::Base
18 class Enumeration < ActiveRecord::Base
19 include Redmine::SubclassFactory
19 include Redmine::SubclassFactory
20
20
21 default_scope :order => "#{Enumeration.table_name}.position ASC"
21 default_scope :order => "#{Enumeration.table_name}.position ASC"
22
22
23 belongs_to :project
23 belongs_to :project
24
24
25 acts_as_list :scope => 'type = \'#{type}\''
25 acts_as_list :scope => 'type = \'#{type}\''
26 acts_as_customizable
26 acts_as_customizable
27 acts_as_tree :order => 'position ASC'
27 acts_as_tree :order => 'position ASC'
28
28
29 before_destroy :check_integrity
29 before_destroy :check_integrity
30 before_save :check_default
30 before_save :check_default
31
31
32 attr_protected :type
32 attr_protected :type
33
33
34 validates_presence_of :name
34 validates_presence_of :name
35 validates_uniqueness_of :name, :scope => [:type, :project_id]
35 validates_uniqueness_of :name, :scope => [:type, :project_id]
36 validates_length_of :name, :maximum => 30
36 validates_length_of :name, :maximum => 30
37
37
38 named_scope :shared, :conditions => { :project_id => nil }
38 scope :shared, :conditions => { :project_id => nil }
39 named_scope :active, :conditions => { :active => true }
39 scope :active, :conditions => { :active => true }
40 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
40 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
41
41
42 def self.default
42 def self.default
43 # Creates a fake default scope so Enumeration.default will check
43 # Creates a fake default scope so Enumeration.default will check
44 # it's type. STI subclasses will automatically add their own
44 # it's type. STI subclasses will automatically add their own
45 # types to the finder.
45 # types to the finder.
46 if self.descends_from_active_record?
46 if self.descends_from_active_record?
47 find(:first, :conditions => { :is_default => true, :type => 'Enumeration' })
47 find(:first, :conditions => { :is_default => true, :type => 'Enumeration' })
48 else
48 else
49 # STI classes are
49 # STI classes are
50 find(:first, :conditions => { :is_default => true })
50 find(:first, :conditions => { :is_default => true })
51 end
51 end
52 end
52 end
53
53
54 # Overloaded on concrete classes
54 # Overloaded on concrete classes
55 def option_name
55 def option_name
56 nil
56 nil
57 end
57 end
58
58
59 def check_default
59 def check_default
60 if is_default? && is_default_changed?
60 if is_default? && is_default_changed?
61 Enumeration.update_all("is_default = #{connection.quoted_false}", {:type => type})
61 Enumeration.update_all("is_default = #{connection.quoted_false}", {:type => type})
62 end
62 end
63 end
63 end
64
64
65 # Overloaded on concrete classes
65 # Overloaded on concrete classes
66 def objects_count
66 def objects_count
67 0
67 0
68 end
68 end
69
69
70 def in_use?
70 def in_use?
71 self.objects_count != 0
71 self.objects_count != 0
72 end
72 end
73
73
74 # Is this enumeration overiding a system level enumeration?
74 # Is this enumeration overiding a system level enumeration?
75 def is_override?
75 def is_override?
76 !self.parent.nil?
76 !self.parent.nil?
77 end
77 end
78
78
79 alias :destroy_without_reassign :destroy
79 alias :destroy_without_reassign :destroy
80
80
81 # Destroy the enumeration
81 # Destroy the enumeration
82 # If a enumeration is specified, objects are reassigned
82 # If a enumeration is specified, objects are reassigned
83 def destroy(reassign_to = nil)
83 def destroy(reassign_to = nil)
84 if reassign_to && reassign_to.is_a?(Enumeration)
84 if reassign_to && reassign_to.is_a?(Enumeration)
85 self.transfer_relations(reassign_to)
85 self.transfer_relations(reassign_to)
86 end
86 end
87 destroy_without_reassign
87 destroy_without_reassign
88 end
88 end
89
89
90 def <=>(enumeration)
90 def <=>(enumeration)
91 position <=> enumeration.position
91 position <=> enumeration.position
92 end
92 end
93
93
94 def to_s; name end
94 def to_s; name end
95
95
96 # Returns the Subclasses of Enumeration. Each Subclass needs to be
96 # Returns the Subclasses of Enumeration. Each Subclass needs to be
97 # required in development mode.
97 # required in development mode.
98 #
98 #
99 # Note: subclasses is protected in ActiveRecord
99 # Note: subclasses is protected in ActiveRecord
100 def self.get_subclasses
100 def self.get_subclasses
101 subclasses
101 subclasses
102 end
102 end
103
103
104 # Does the +new+ Hash override the previous Enumeration?
104 # Does the +new+ Hash override the previous Enumeration?
105 def self.overridding_change?(new, previous)
105 def self.overridding_change?(new, previous)
106 if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
106 if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
107 return false
107 return false
108 else
108 else
109 return true
109 return true
110 end
110 end
111 end
111 end
112
112
113 # Does the +new+ Hash have the same custom values as the previous Enumeration?
113 # Does the +new+ Hash have the same custom values as the previous Enumeration?
114 def self.same_custom_values?(new, previous)
114 def self.same_custom_values?(new, previous)
115 previous.custom_field_values.each do |custom_value|
115 previous.custom_field_values.each do |custom_value|
116 if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
116 if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
117 return false
117 return false
118 end
118 end
119 end
119 end
120
120
121 return true
121 return true
122 end
122 end
123
123
124 # Are the new and previous fields equal?
124 # Are the new and previous fields equal?
125 def self.same_active_state?(new, previous)
125 def self.same_active_state?(new, previous)
126 new = (new == "1" ? true : false)
126 new = (new == "1" ? true : false)
127 return new == previous
127 return new == previous
128 end
128 end
129
129
130 private
130 private
131 def check_integrity
131 def check_integrity
132 raise "Can't delete enumeration" if self.in_use?
132 raise "Can't delete enumeration" if self.in_use?
133 end
133 end
134
134
135 end
135 end
136
136
137 # Force load the subclasses in development mode
137 # Force load the subclasses in development mode
138 require_dependency 'time_entry_activity'
138 require_dependency 'time_entry_activity'
139 require_dependency 'document_category'
139 require_dependency 'document_category'
140 require_dependency 'issue_priority'
140 require_dependency 'issue_priority'
@@ -1,1078 +1,1079
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 belongs_to :project
21 belongs_to :project
22 belongs_to :tracker
22 belongs_to :tracker
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29
29
30 has_many :journals, :as => :journalized, :dependent => :destroy
30 has_many :journals, :as => :journalized, :dependent => :destroy
31 has_many :time_entries, :dependent => :delete_all
31 has_many :time_entries, :dependent => :delete_all
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33
33
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36
36
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 acts_as_customizable
39 acts_as_customizable
40 acts_as_watchable
40 acts_as_watchable
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 :include => [:project, :journals],
42 :include => [:project, :journals],
43 # sort by id so that limited eager loading doesn't break with postgresql
43 # sort by id so that limited eager loading doesn't break with postgresql
44 :order_column => "#{table_name}.id"
44 :order_column => "#{table_name}.id"
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48
48
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 :author_key => :author_id
50 :author_key => :author_id
51
51
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53
53
54 attr_reader :current_journal
54 attr_reader :current_journal
55
55
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57
57
58 validates_length_of :subject, :maximum => 255
58 validates_length_of :subject, :maximum => 255
59 validates_inclusion_of :done_ratio, :in => 0..100
59 validates_inclusion_of :done_ratio, :in => 0..100
60 validates_numericality_of :estimated_hours, :allow_nil => true
60 validates_numericality_of :estimated_hours, :allow_nil => true
61 validate :validate_issue
61 validate :validate_issue
62
62
63 named_scope :visible, lambda {|*args| { :include => :project,
63 scope :visible,
64 lambda {|*args| { :include => :project,
64 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65
66
66 named_scope :open, lambda {|*args|
67 scope :open, lambda {|*args|
67 is_closed = args.size > 0 ? !args.first : false
68 is_closed = args.size > 0 ? !args.first : false
68 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
69 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
69 }
70 }
70
71
71 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
72 scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
72 named_scope :with_limit, lambda { |limit| { :limit => limit} }
73 scope :with_limit, lambda { |limit| { :limit => limit} }
73 named_scope :on_active_project, :include => [:status, :project, :tracker],
74 scope :on_active_project, :include => [:status, :project, :tracker],
74 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
75 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
75
76
76 before_create :default_assign
77 before_create :default_assign
77 before_save :close_duplicates, :update_done_ratio_from_issue_status
78 before_save :close_duplicates, :update_done_ratio_from_issue_status
78 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
79 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
79 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
80 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
80 after_destroy :update_parent_attributes
81 after_destroy :update_parent_attributes
81
82
82 # Returns a SQL conditions string used to find all issues visible by the specified user
83 # Returns a SQL conditions string used to find all issues visible by the specified user
83 def self.visible_condition(user, options={})
84 def self.visible_condition(user, options={})
84 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
85 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
85 case role.issues_visibility
86 case role.issues_visibility
86 when 'all'
87 when 'all'
87 nil
88 nil
88 when 'default'
89 when 'default'
89 user_ids = [user.id] + user.groups.map(&:id)
90 user_ids = [user.id] + user.groups.map(&:id)
90 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
91 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
91 when 'own'
92 when 'own'
92 user_ids = [user.id] + user.groups.map(&:id)
93 user_ids = [user.id] + user.groups.map(&:id)
93 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
94 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
94 else
95 else
95 '1=0'
96 '1=0'
96 end
97 end
97 end
98 end
98 end
99 end
99
100
100 # Returns true if usr or current user is allowed to view the issue
101 # Returns true if usr or current user is allowed to view the issue
101 def visible?(usr=nil)
102 def visible?(usr=nil)
102 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
103 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
103 case role.issues_visibility
104 case role.issues_visibility
104 when 'all'
105 when 'all'
105 true
106 true
106 when 'default'
107 when 'default'
107 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
108 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
108 when 'own'
109 when 'own'
109 self.author == user || user.is_or_belongs_to?(assigned_to)
110 self.author == user || user.is_or_belongs_to?(assigned_to)
110 else
111 else
111 false
112 false
112 end
113 end
113 end
114 end
114 end
115 end
115
116
116 def initialize(attributes=nil, *args)
117 def initialize(attributes=nil, *args)
117 super
118 super
118 if new_record?
119 if new_record?
119 # set default values for new records only
120 # set default values for new records only
120 self.status ||= IssueStatus.default
121 self.status ||= IssueStatus.default
121 self.priority ||= IssuePriority.default
122 self.priority ||= IssuePriority.default
122 self.watcher_user_ids = []
123 self.watcher_user_ids = []
123 end
124 end
124 end
125 end
125
126
126 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
127 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
127 def available_custom_fields
128 def available_custom_fields
128 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
129 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
129 end
130 end
130
131
131 # Copies attributes from another issue, arg can be an id or an Issue
132 # Copies attributes from another issue, arg can be an id or an Issue
132 def copy_from(arg, options={})
133 def copy_from(arg, options={})
133 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
134 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
134 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
135 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
135 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
136 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
136 self.status = issue.status
137 self.status = issue.status
137 self.author = User.current
138 self.author = User.current
138 unless options[:attachments] == false
139 unless options[:attachments] == false
139 self.attachments = issue.attachments.map do |attachement|
140 self.attachments = issue.attachments.map do |attachement|
140 attachement.copy(:container => self)
141 attachement.copy(:container => self)
141 end
142 end
142 end
143 end
143 @copied_from = issue
144 @copied_from = issue
144 self
145 self
145 end
146 end
146
147
147 # Returns an unsaved copy of the issue
148 # Returns an unsaved copy of the issue
148 def copy(attributes=nil, copy_options={})
149 def copy(attributes=nil, copy_options={})
149 copy = self.class.new.copy_from(self, copy_options)
150 copy = self.class.new.copy_from(self, copy_options)
150 copy.attributes = attributes if attributes
151 copy.attributes = attributes if attributes
151 copy
152 copy
152 end
153 end
153
154
154 # Returns true if the issue is a copy
155 # Returns true if the issue is a copy
155 def copy?
156 def copy?
156 @copied_from.present?
157 @copied_from.present?
157 end
158 end
158
159
159 # Moves/copies an issue to a new project and tracker
160 # Moves/copies an issue to a new project and tracker
160 # Returns the moved/copied issue on success, false on failure
161 # Returns the moved/copied issue on success, false on failure
161 def move_to_project(new_project, new_tracker=nil, options={})
162 def move_to_project(new_project, new_tracker=nil, options={})
162 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
163 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
163
164
164 if options[:copy]
165 if options[:copy]
165 issue = self.copy
166 issue = self.copy
166 else
167 else
167 issue = self
168 issue = self
168 end
169 end
169
170
170 issue.init_journal(User.current, options[:notes])
171 issue.init_journal(User.current, options[:notes])
171
172
172 # Preserve previous behaviour
173 # Preserve previous behaviour
173 # #move_to_project doesn't change tracker automatically
174 # #move_to_project doesn't change tracker automatically
174 issue.send :project=, new_project, true
175 issue.send :project=, new_project, true
175 if new_tracker
176 if new_tracker
176 issue.tracker = new_tracker
177 issue.tracker = new_tracker
177 end
178 end
178 # Allow bulk setting of attributes on the issue
179 # Allow bulk setting of attributes on the issue
179 if options[:attributes]
180 if options[:attributes]
180 issue.attributes = options[:attributes]
181 issue.attributes = options[:attributes]
181 end
182 end
182
183
183 issue.save ? issue : false
184 issue.save ? issue : false
184 end
185 end
185
186
186 def status_id=(sid)
187 def status_id=(sid)
187 self.status = nil
188 self.status = nil
188 write_attribute(:status_id, sid)
189 write_attribute(:status_id, sid)
189 end
190 end
190
191
191 def priority_id=(pid)
192 def priority_id=(pid)
192 self.priority = nil
193 self.priority = nil
193 write_attribute(:priority_id, pid)
194 write_attribute(:priority_id, pid)
194 end
195 end
195
196
196 def category_id=(cid)
197 def category_id=(cid)
197 self.category = nil
198 self.category = nil
198 write_attribute(:category_id, cid)
199 write_attribute(:category_id, cid)
199 end
200 end
200
201
201 def fixed_version_id=(vid)
202 def fixed_version_id=(vid)
202 self.fixed_version = nil
203 self.fixed_version = nil
203 write_attribute(:fixed_version_id, vid)
204 write_attribute(:fixed_version_id, vid)
204 end
205 end
205
206
206 def tracker_id=(tid)
207 def tracker_id=(tid)
207 self.tracker = nil
208 self.tracker = nil
208 result = write_attribute(:tracker_id, tid)
209 result = write_attribute(:tracker_id, tid)
209 @custom_field_values = nil
210 @custom_field_values = nil
210 result
211 result
211 end
212 end
212
213
213 def project_id=(project_id)
214 def project_id=(project_id)
214 if project_id.to_s != self.project_id.to_s
215 if project_id.to_s != self.project_id.to_s
215 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
216 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
216 end
217 end
217 end
218 end
218
219
219 def project=(project, keep_tracker=false)
220 def project=(project, keep_tracker=false)
220 project_was = self.project
221 project_was = self.project
221 write_attribute(:project_id, project ? project.id : nil)
222 write_attribute(:project_id, project ? project.id : nil)
222 association_instance_set('project', project)
223 association_instance_set('project', project)
223 if project_was && project && project_was != project
224 if project_was && project && project_was != project
224 unless keep_tracker || project.trackers.include?(tracker)
225 unless keep_tracker || project.trackers.include?(tracker)
225 self.tracker = project.trackers.first
226 self.tracker = project.trackers.first
226 end
227 end
227 # Reassign to the category with same name if any
228 # Reassign to the category with same name if any
228 if category
229 if category
229 self.category = project.issue_categories.find_by_name(category.name)
230 self.category = project.issue_categories.find_by_name(category.name)
230 end
231 end
231 # Keep the fixed_version if it's still valid in the new_project
232 # Keep the fixed_version if it's still valid in the new_project
232 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
233 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
233 self.fixed_version = nil
234 self.fixed_version = nil
234 end
235 end
235 if parent && parent.project_id != project_id
236 if parent && parent.project_id != project_id
236 self.parent_issue_id = nil
237 self.parent_issue_id = nil
237 end
238 end
238 @custom_field_values = nil
239 @custom_field_values = nil
239 end
240 end
240 end
241 end
241
242
242 def description=(arg)
243 def description=(arg)
243 if arg.is_a?(String)
244 if arg.is_a?(String)
244 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
245 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
245 end
246 end
246 write_attribute(:description, arg)
247 write_attribute(:description, arg)
247 end
248 end
248
249
249 # Overrides assign_attributes so that project and tracker get assigned first
250 # Overrides assign_attributes so that project and tracker get assigned first
250 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
251 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
251 return if new_attributes.nil?
252 return if new_attributes.nil?
252 attrs = new_attributes.dup
253 attrs = new_attributes.dup
253 attrs.stringify_keys!
254 attrs.stringify_keys!
254
255
255 %w(project project_id tracker tracker_id).each do |attr|
256 %w(project project_id tracker tracker_id).each do |attr|
256 if attrs.has_key?(attr)
257 if attrs.has_key?(attr)
257 send "#{attr}=", attrs.delete(attr)
258 send "#{attr}=", attrs.delete(attr)
258 end
259 end
259 end
260 end
260 send :assign_attributes_without_project_and_tracker_first, attrs, *args
261 send :assign_attributes_without_project_and_tracker_first, attrs, *args
261 end
262 end
262 # Do not redefine alias chain on reload (see #4838)
263 # Do not redefine alias chain on reload (see #4838)
263 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
264 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
264
265
265 def estimated_hours=(h)
266 def estimated_hours=(h)
266 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
267 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
267 end
268 end
268
269
269 safe_attributes 'project_id',
270 safe_attributes 'project_id',
270 :if => lambda {|issue, user|
271 :if => lambda {|issue, user|
271 if issue.new_record?
272 if issue.new_record?
272 issue.copy?
273 issue.copy?
273 elsif user.allowed_to?(:move_issues, issue.project)
274 elsif user.allowed_to?(:move_issues, issue.project)
274 projects = Issue.allowed_target_projects_on_move(user)
275 projects = Issue.allowed_target_projects_on_move(user)
275 projects.include?(issue.project) && projects.size > 1
276 projects.include?(issue.project) && projects.size > 1
276 end
277 end
277 }
278 }
278
279
279 safe_attributes 'tracker_id',
280 safe_attributes 'tracker_id',
280 'status_id',
281 'status_id',
281 'category_id',
282 'category_id',
282 'assigned_to_id',
283 'assigned_to_id',
283 'priority_id',
284 'priority_id',
284 'fixed_version_id',
285 'fixed_version_id',
285 'subject',
286 'subject',
286 'description',
287 'description',
287 'start_date',
288 'start_date',
288 'due_date',
289 'due_date',
289 'done_ratio',
290 'done_ratio',
290 'estimated_hours',
291 'estimated_hours',
291 'custom_field_values',
292 'custom_field_values',
292 'custom_fields',
293 'custom_fields',
293 'lock_version',
294 'lock_version',
294 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
295 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
295
296
296 safe_attributes 'status_id',
297 safe_attributes 'status_id',
297 'assigned_to_id',
298 'assigned_to_id',
298 'fixed_version_id',
299 'fixed_version_id',
299 'done_ratio',
300 'done_ratio',
300 'lock_version',
301 'lock_version',
301 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
302 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
302
303
303 safe_attributes 'watcher_user_ids',
304 safe_attributes 'watcher_user_ids',
304 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
305 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
305
306
306 safe_attributes 'is_private',
307 safe_attributes 'is_private',
307 :if => lambda {|issue, user|
308 :if => lambda {|issue, user|
308 user.allowed_to?(:set_issues_private, issue.project) ||
309 user.allowed_to?(:set_issues_private, issue.project) ||
309 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
310 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
310 }
311 }
311
312
312 safe_attributes 'parent_issue_id',
313 safe_attributes 'parent_issue_id',
313 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
314 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
314 user.allowed_to?(:manage_subtasks, issue.project)}
315 user.allowed_to?(:manage_subtasks, issue.project)}
315
316
316 # Safely sets attributes
317 # Safely sets attributes
317 # Should be called from controllers instead of #attributes=
318 # Should be called from controllers instead of #attributes=
318 # attr_accessible is too rough because we still want things like
319 # attr_accessible is too rough because we still want things like
319 # Issue.new(:project => foo) to work
320 # Issue.new(:project => foo) to work
320 def safe_attributes=(attrs, user=User.current)
321 def safe_attributes=(attrs, user=User.current)
321 return unless attrs.is_a?(Hash)
322 return unless attrs.is_a?(Hash)
322
323
323 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
324 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
324 attrs = delete_unsafe_attributes(attrs, user)
325 attrs = delete_unsafe_attributes(attrs, user)
325 return if attrs.empty?
326 return if attrs.empty?
326
327
327 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
328 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
328 if p = attrs.delete('project_id')
329 if p = attrs.delete('project_id')
329 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
330 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
330 self.project_id = p
331 self.project_id = p
331 end
332 end
332 end
333 end
333
334
334 if t = attrs.delete('tracker_id')
335 if t = attrs.delete('tracker_id')
335 self.tracker_id = t
336 self.tracker_id = t
336 end
337 end
337
338
338 if attrs['status_id']
339 if attrs['status_id']
339 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
340 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
340 attrs.delete('status_id')
341 attrs.delete('status_id')
341 end
342 end
342 end
343 end
343
344
344 unless leaf?
345 unless leaf?
345 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
346 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
346 end
347 end
347
348
348 if attrs['parent_issue_id'].present?
349 if attrs['parent_issue_id'].present?
349 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
350 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
350 end
351 end
351
352
352 # mass-assignment security bypass
353 # mass-assignment security bypass
353 assign_attributes attrs, :without_protection => true
354 assign_attributes attrs, :without_protection => true
354 end
355 end
355
356
356 def done_ratio
357 def done_ratio
357 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
358 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
358 status.default_done_ratio
359 status.default_done_ratio
359 else
360 else
360 read_attribute(:done_ratio)
361 read_attribute(:done_ratio)
361 end
362 end
362 end
363 end
363
364
364 def self.use_status_for_done_ratio?
365 def self.use_status_for_done_ratio?
365 Setting.issue_done_ratio == 'issue_status'
366 Setting.issue_done_ratio == 'issue_status'
366 end
367 end
367
368
368 def self.use_field_for_done_ratio?
369 def self.use_field_for_done_ratio?
369 Setting.issue_done_ratio == 'issue_field'
370 Setting.issue_done_ratio == 'issue_field'
370 end
371 end
371
372
372 def validate_issue
373 def validate_issue
373 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
374 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
374 errors.add :due_date, :not_a_date
375 errors.add :due_date, :not_a_date
375 end
376 end
376
377
377 if self.due_date and self.start_date and self.due_date < self.start_date
378 if self.due_date and self.start_date and self.due_date < self.start_date
378 errors.add :due_date, :greater_than_start_date
379 errors.add :due_date, :greater_than_start_date
379 end
380 end
380
381
381 if start_date && soonest_start && start_date < soonest_start
382 if start_date && soonest_start && start_date < soonest_start
382 errors.add :start_date, :invalid
383 errors.add :start_date, :invalid
383 end
384 end
384
385
385 if fixed_version
386 if fixed_version
386 if !assignable_versions.include?(fixed_version)
387 if !assignable_versions.include?(fixed_version)
387 errors.add :fixed_version_id, :inclusion
388 errors.add :fixed_version_id, :inclusion
388 elsif reopened? && fixed_version.closed?
389 elsif reopened? && fixed_version.closed?
389 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
390 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
390 end
391 end
391 end
392 end
392
393
393 # Checks that the issue can not be added/moved to a disabled tracker
394 # Checks that the issue can not be added/moved to a disabled tracker
394 if project && (tracker_id_changed? || project_id_changed?)
395 if project && (tracker_id_changed? || project_id_changed?)
395 unless project.trackers.include?(tracker)
396 unless project.trackers.include?(tracker)
396 errors.add :tracker_id, :inclusion
397 errors.add :tracker_id, :inclusion
397 end
398 end
398 end
399 end
399
400
400 # Checks parent issue assignment
401 # Checks parent issue assignment
401 if @parent_issue
402 if @parent_issue
402 if @parent_issue.project_id != project_id
403 if @parent_issue.project_id != project_id
403 errors.add :parent_issue_id, :not_same_project
404 errors.add :parent_issue_id, :not_same_project
404 elsif !new_record?
405 elsif !new_record?
405 # moving an existing issue
406 # moving an existing issue
406 if @parent_issue.root_id != root_id
407 if @parent_issue.root_id != root_id
407 # we can always move to another tree
408 # we can always move to another tree
408 elsif move_possible?(@parent_issue)
409 elsif move_possible?(@parent_issue)
409 # move accepted inside tree
410 # move accepted inside tree
410 else
411 else
411 errors.add :parent_issue_id, :not_a_valid_parent
412 errors.add :parent_issue_id, :not_a_valid_parent
412 end
413 end
413 end
414 end
414 end
415 end
415 end
416 end
416
417
417 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
418 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
418 # even if the user turns off the setting later
419 # even if the user turns off the setting later
419 def update_done_ratio_from_issue_status
420 def update_done_ratio_from_issue_status
420 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
421 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
421 self.done_ratio = status.default_done_ratio
422 self.done_ratio = status.default_done_ratio
422 end
423 end
423 end
424 end
424
425
425 def init_journal(user, notes = "")
426 def init_journal(user, notes = "")
426 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
427 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
427 if new_record?
428 if new_record?
428 @current_journal.notify = false
429 @current_journal.notify = false
429 else
430 else
430 @attributes_before_change = attributes.dup
431 @attributes_before_change = attributes.dup
431 @custom_values_before_change = {}
432 @custom_values_before_change = {}
432 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
433 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
433 end
434 end
434 # Make sure updated_on is updated when adding a note.
435 # Make sure updated_on is updated when adding a note.
435 updated_on_will_change!
436 updated_on_will_change!
436 @current_journal
437 @current_journal
437 end
438 end
438
439
439 # Returns the id of the last journal or nil
440 # Returns the id of the last journal or nil
440 def last_journal_id
441 def last_journal_id
441 if new_record?
442 if new_record?
442 nil
443 nil
443 else
444 else
444 journals.first(:order => "#{Journal.table_name}.id DESC").try(:id)
445 journals.first(:order => "#{Journal.table_name}.id DESC").try(:id)
445 end
446 end
446 end
447 end
447
448
448 # Return true if the issue is closed, otherwise false
449 # Return true if the issue is closed, otherwise false
449 def closed?
450 def closed?
450 self.status.is_closed?
451 self.status.is_closed?
451 end
452 end
452
453
453 # Return true if the issue is being reopened
454 # Return true if the issue is being reopened
454 def reopened?
455 def reopened?
455 if !new_record? && status_id_changed?
456 if !new_record? && status_id_changed?
456 status_was = IssueStatus.find_by_id(status_id_was)
457 status_was = IssueStatus.find_by_id(status_id_was)
457 status_new = IssueStatus.find_by_id(status_id)
458 status_new = IssueStatus.find_by_id(status_id)
458 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
459 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
459 return true
460 return true
460 end
461 end
461 end
462 end
462 false
463 false
463 end
464 end
464
465
465 # Return true if the issue is being closed
466 # Return true if the issue is being closed
466 def closing?
467 def closing?
467 if !new_record? && status_id_changed?
468 if !new_record? && status_id_changed?
468 status_was = IssueStatus.find_by_id(status_id_was)
469 status_was = IssueStatus.find_by_id(status_id_was)
469 status_new = IssueStatus.find_by_id(status_id)
470 status_new = IssueStatus.find_by_id(status_id)
470 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
471 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
471 return true
472 return true
472 end
473 end
473 end
474 end
474 false
475 false
475 end
476 end
476
477
477 # Returns true if the issue is overdue
478 # Returns true if the issue is overdue
478 def overdue?
479 def overdue?
479 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
480 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
480 end
481 end
481
482
482 # Is the amount of work done less than it should for the due date
483 # Is the amount of work done less than it should for the due date
483 def behind_schedule?
484 def behind_schedule?
484 return false if start_date.nil? || due_date.nil?
485 return false if start_date.nil? || due_date.nil?
485 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
486 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
486 return done_date <= Date.today
487 return done_date <= Date.today
487 end
488 end
488
489
489 # Does this issue have children?
490 # Does this issue have children?
490 def children?
491 def children?
491 !leaf?
492 !leaf?
492 end
493 end
493
494
494 # Users the issue can be assigned to
495 # Users the issue can be assigned to
495 def assignable_users
496 def assignable_users
496 users = project.assignable_users
497 users = project.assignable_users
497 users << author if author
498 users << author if author
498 users << assigned_to if assigned_to
499 users << assigned_to if assigned_to
499 users.uniq.sort
500 users.uniq.sort
500 end
501 end
501
502
502 # Versions that the issue can be assigned to
503 # Versions that the issue can be assigned to
503 def assignable_versions
504 def assignable_versions
504 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
505 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
505 end
506 end
506
507
507 # Returns true if this issue is blocked by another issue that is still open
508 # Returns true if this issue is blocked by another issue that is still open
508 def blocked?
509 def blocked?
509 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
510 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
510 end
511 end
511
512
512 # Returns an array of statuses that user is able to apply
513 # Returns an array of statuses that user is able to apply
513 def new_statuses_allowed_to(user=User.current, include_default=false)
514 def new_statuses_allowed_to(user=User.current, include_default=false)
514 if new_record? && @copied_from
515 if new_record? && @copied_from
515 [IssueStatus.default, @copied_from.status].compact.uniq.sort
516 [IssueStatus.default, @copied_from.status].compact.uniq.sort
516 else
517 else
517 initial_status = nil
518 initial_status = nil
518 if new_record?
519 if new_record?
519 initial_status = IssueStatus.default
520 initial_status = IssueStatus.default
520 elsif status_id_was
521 elsif status_id_was
521 initial_status = IssueStatus.find_by_id(status_id_was)
522 initial_status = IssueStatus.find_by_id(status_id_was)
522 end
523 end
523 initial_status ||= status
524 initial_status ||= status
524
525
525 statuses = initial_status.find_new_statuses_allowed_to(
526 statuses = initial_status.find_new_statuses_allowed_to(
526 user.admin ? Role.all : user.roles_for_project(project),
527 user.admin ? Role.all : user.roles_for_project(project),
527 tracker,
528 tracker,
528 author == user,
529 author == user,
529 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
530 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
530 )
531 )
531 statuses << initial_status unless statuses.empty?
532 statuses << initial_status unless statuses.empty?
532 statuses << IssueStatus.default if include_default
533 statuses << IssueStatus.default if include_default
533 statuses = statuses.compact.uniq.sort
534 statuses = statuses.compact.uniq.sort
534 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
535 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
535 end
536 end
536 end
537 end
537
538
538 def assigned_to_was
539 def assigned_to_was
539 if assigned_to_id_changed? && assigned_to_id_was.present?
540 if assigned_to_id_changed? && assigned_to_id_was.present?
540 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
541 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
541 end
542 end
542 end
543 end
543
544
544 # Returns the mail adresses of users that should be notified
545 # Returns the mail adresses of users that should be notified
545 def recipients
546 def recipients
546 notified = []
547 notified = []
547 # Author and assignee are always notified unless they have been
548 # Author and assignee are always notified unless they have been
548 # locked or don't want to be notified
549 # locked or don't want to be notified
549 notified << author if author
550 notified << author if author
550 if assigned_to
551 if assigned_to
551 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
552 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
552 end
553 end
553 if assigned_to_was
554 if assigned_to_was
554 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
555 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
555 end
556 end
556 notified = notified.select {|u| u.active? && u.notify_about?(self)}
557 notified = notified.select {|u| u.active? && u.notify_about?(self)}
557
558
558 notified += project.notified_users
559 notified += project.notified_users
559 notified.uniq!
560 notified.uniq!
560 # Remove users that can not view the issue
561 # Remove users that can not view the issue
561 notified.reject! {|user| !visible?(user)}
562 notified.reject! {|user| !visible?(user)}
562 notified.collect(&:mail)
563 notified.collect(&:mail)
563 end
564 end
564
565
565 # Returns the number of hours spent on this issue
566 # Returns the number of hours spent on this issue
566 def spent_hours
567 def spent_hours
567 @spent_hours ||= time_entries.sum(:hours) || 0
568 @spent_hours ||= time_entries.sum(:hours) || 0
568 end
569 end
569
570
570 # Returns the total number of hours spent on this issue and its descendants
571 # Returns the total number of hours spent on this issue and its descendants
571 #
572 #
572 # Example:
573 # Example:
573 # spent_hours => 0.0
574 # spent_hours => 0.0
574 # spent_hours => 50.2
575 # spent_hours => 50.2
575 def total_spent_hours
576 def total_spent_hours
576 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
577 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
577 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
578 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
578 end
579 end
579
580
580 def relations
581 def relations
581 @relations ||= (relations_from + relations_to).sort
582 @relations ||= (relations_from + relations_to).sort
582 end
583 end
583
584
584 # Preloads relations for a collection of issues
585 # Preloads relations for a collection of issues
585 def self.load_relations(issues)
586 def self.load_relations(issues)
586 if issues.any?
587 if issues.any?
587 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
588 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
588 issues.each do |issue|
589 issues.each do |issue|
589 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
590 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
590 end
591 end
591 end
592 end
592 end
593 end
593
594
594 # Preloads visible spent time for a collection of issues
595 # Preloads visible spent time for a collection of issues
595 def self.load_visible_spent_hours(issues, user=User.current)
596 def self.load_visible_spent_hours(issues, user=User.current)
596 if issues.any?
597 if issues.any?
597 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
598 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
598 issues.each do |issue|
599 issues.each do |issue|
599 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
600 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
600 end
601 end
601 end
602 end
602 end
603 end
603
604
604 # Finds an issue relation given its id.
605 # Finds an issue relation given its id.
605 def find_relation(relation_id)
606 def find_relation(relation_id)
606 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
607 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
607 end
608 end
608
609
609 def all_dependent_issues(except=[])
610 def all_dependent_issues(except=[])
610 except << self
611 except << self
611 dependencies = []
612 dependencies = []
612 relations_from.each do |relation|
613 relations_from.each do |relation|
613 if relation.issue_to && !except.include?(relation.issue_to)
614 if relation.issue_to && !except.include?(relation.issue_to)
614 dependencies << relation.issue_to
615 dependencies << relation.issue_to
615 dependencies += relation.issue_to.all_dependent_issues(except)
616 dependencies += relation.issue_to.all_dependent_issues(except)
616 end
617 end
617 end
618 end
618 dependencies
619 dependencies
619 end
620 end
620
621
621 # Returns an array of issues that duplicate this one
622 # Returns an array of issues that duplicate this one
622 def duplicates
623 def duplicates
623 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
624 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
624 end
625 end
625
626
626 # Returns the due date or the target due date if any
627 # Returns the due date or the target due date if any
627 # Used on gantt chart
628 # Used on gantt chart
628 def due_before
629 def due_before
629 due_date || (fixed_version ? fixed_version.effective_date : nil)
630 due_date || (fixed_version ? fixed_version.effective_date : nil)
630 end
631 end
631
632
632 # Returns the time scheduled for this issue.
633 # Returns the time scheduled for this issue.
633 #
634 #
634 # Example:
635 # Example:
635 # Start Date: 2/26/09, End Date: 3/04/09
636 # Start Date: 2/26/09, End Date: 3/04/09
636 # duration => 6
637 # duration => 6
637 def duration
638 def duration
638 (start_date && due_date) ? due_date - start_date : 0
639 (start_date && due_date) ? due_date - start_date : 0
639 end
640 end
640
641
641 def soonest_start
642 def soonest_start
642 @soonest_start ||= (
643 @soonest_start ||= (
643 relations_to.collect{|relation| relation.successor_soonest_start} +
644 relations_to.collect{|relation| relation.successor_soonest_start} +
644 ancestors.collect(&:soonest_start)
645 ancestors.collect(&:soonest_start)
645 ).compact.max
646 ).compact.max
646 end
647 end
647
648
648 def reschedule_after(date)
649 def reschedule_after(date)
649 return if date.nil?
650 return if date.nil?
650 if leaf?
651 if leaf?
651 if start_date.nil? || start_date < date
652 if start_date.nil? || start_date < date
652 self.start_date, self.due_date = date, date + duration
653 self.start_date, self.due_date = date, date + duration
653 begin
654 begin
654 save
655 save
655 rescue ActiveRecord::StaleObjectError
656 rescue ActiveRecord::StaleObjectError
656 reload
657 reload
657 self.start_date, self.due_date = date, date + duration
658 self.start_date, self.due_date = date, date + duration
658 save
659 save
659 end
660 end
660 end
661 end
661 else
662 else
662 leaves.each do |leaf|
663 leaves.each do |leaf|
663 leaf.reschedule_after(date)
664 leaf.reschedule_after(date)
664 end
665 end
665 end
666 end
666 end
667 end
667
668
668 def <=>(issue)
669 def <=>(issue)
669 if issue.nil?
670 if issue.nil?
670 -1
671 -1
671 elsif root_id != issue.root_id
672 elsif root_id != issue.root_id
672 (root_id || 0) <=> (issue.root_id || 0)
673 (root_id || 0) <=> (issue.root_id || 0)
673 else
674 else
674 (lft || 0) <=> (issue.lft || 0)
675 (lft || 0) <=> (issue.lft || 0)
675 end
676 end
676 end
677 end
677
678
678 def to_s
679 def to_s
679 "#{tracker} ##{id}: #{subject}"
680 "#{tracker} ##{id}: #{subject}"
680 end
681 end
681
682
682 # Returns a string of css classes that apply to the issue
683 # Returns a string of css classes that apply to the issue
683 def css_classes
684 def css_classes
684 s = "issue status-#{status.position} priority-#{priority.position}"
685 s = "issue status-#{status.position} priority-#{priority.position}"
685 s << ' closed' if closed?
686 s << ' closed' if closed?
686 s << ' overdue' if overdue?
687 s << ' overdue' if overdue?
687 s << ' child' if child?
688 s << ' child' if child?
688 s << ' parent' unless leaf?
689 s << ' parent' unless leaf?
689 s << ' private' if is_private?
690 s << ' private' if is_private?
690 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
691 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
691 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
692 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
692 s
693 s
693 end
694 end
694
695
695 # Saves an issue and a time_entry from the parameters
696 # Saves an issue and a time_entry from the parameters
696 def save_issue_with_child_records(params, existing_time_entry=nil)
697 def save_issue_with_child_records(params, existing_time_entry=nil)
697 Issue.transaction do
698 Issue.transaction do
698 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
699 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
699 @time_entry = existing_time_entry || TimeEntry.new
700 @time_entry = existing_time_entry || TimeEntry.new
700 @time_entry.project = project
701 @time_entry.project = project
701 @time_entry.issue = self
702 @time_entry.issue = self
702 @time_entry.user = User.current
703 @time_entry.user = User.current
703 @time_entry.spent_on = User.current.today
704 @time_entry.spent_on = User.current.today
704 @time_entry.attributes = params[:time_entry]
705 @time_entry.attributes = params[:time_entry]
705 self.time_entries << @time_entry
706 self.time_entries << @time_entry
706 end
707 end
707
708
708 # TODO: Rename hook
709 # TODO: Rename hook
709 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
710 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
710 if save
711 if save
711 # TODO: Rename hook
712 # TODO: Rename hook
712 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
713 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
713 else
714 else
714 raise ActiveRecord::Rollback
715 raise ActiveRecord::Rollback
715 end
716 end
716 end
717 end
717 end
718 end
718
719
719 # Unassigns issues from +version+ if it's no longer shared with issue's project
720 # Unassigns issues from +version+ if it's no longer shared with issue's project
720 def self.update_versions_from_sharing_change(version)
721 def self.update_versions_from_sharing_change(version)
721 # Update issues assigned to the version
722 # Update issues assigned to the version
722 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
723 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
723 end
724 end
724
725
725 # Unassigns issues from versions that are no longer shared
726 # Unassigns issues from versions that are no longer shared
726 # after +project+ was moved
727 # after +project+ was moved
727 def self.update_versions_from_hierarchy_change(project)
728 def self.update_versions_from_hierarchy_change(project)
728 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
729 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
729 # Update issues of the moved projects and issues assigned to a version of a moved project
730 # Update issues of the moved projects and issues assigned to a version of a moved project
730 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
731 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
731 end
732 end
732
733
733 def parent_issue_id=(arg)
734 def parent_issue_id=(arg)
734 parent_issue_id = arg.blank? ? nil : arg.to_i
735 parent_issue_id = arg.blank? ? nil : arg.to_i
735 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
736 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
736 @parent_issue.id
737 @parent_issue.id
737 else
738 else
738 @parent_issue = nil
739 @parent_issue = nil
739 nil
740 nil
740 end
741 end
741 end
742 end
742
743
743 def parent_issue_id
744 def parent_issue_id
744 if instance_variable_defined? :@parent_issue
745 if instance_variable_defined? :@parent_issue
745 @parent_issue.nil? ? nil : @parent_issue.id
746 @parent_issue.nil? ? nil : @parent_issue.id
746 else
747 else
747 parent_id
748 parent_id
748 end
749 end
749 end
750 end
750
751
751 # Extracted from the ReportsController.
752 # Extracted from the ReportsController.
752 def self.by_tracker(project)
753 def self.by_tracker(project)
753 count_and_group_by(:project => project,
754 count_and_group_by(:project => project,
754 :field => 'tracker_id',
755 :field => 'tracker_id',
755 :joins => Tracker.table_name)
756 :joins => Tracker.table_name)
756 end
757 end
757
758
758 def self.by_version(project)
759 def self.by_version(project)
759 count_and_group_by(:project => project,
760 count_and_group_by(:project => project,
760 :field => 'fixed_version_id',
761 :field => 'fixed_version_id',
761 :joins => Version.table_name)
762 :joins => Version.table_name)
762 end
763 end
763
764
764 def self.by_priority(project)
765 def self.by_priority(project)
765 count_and_group_by(:project => project,
766 count_and_group_by(:project => project,
766 :field => 'priority_id',
767 :field => 'priority_id',
767 :joins => IssuePriority.table_name)
768 :joins => IssuePriority.table_name)
768 end
769 end
769
770
770 def self.by_category(project)
771 def self.by_category(project)
771 count_and_group_by(:project => project,
772 count_and_group_by(:project => project,
772 :field => 'category_id',
773 :field => 'category_id',
773 :joins => IssueCategory.table_name)
774 :joins => IssueCategory.table_name)
774 end
775 end
775
776
776 def self.by_assigned_to(project)
777 def self.by_assigned_to(project)
777 count_and_group_by(:project => project,
778 count_and_group_by(:project => project,
778 :field => 'assigned_to_id',
779 :field => 'assigned_to_id',
779 :joins => User.table_name)
780 :joins => User.table_name)
780 end
781 end
781
782
782 def self.by_author(project)
783 def self.by_author(project)
783 count_and_group_by(:project => project,
784 count_and_group_by(:project => project,
784 :field => 'author_id',
785 :field => 'author_id',
785 :joins => User.table_name)
786 :joins => User.table_name)
786 end
787 end
787
788
788 def self.by_subproject(project)
789 def self.by_subproject(project)
789 ActiveRecord::Base.connection.select_all("select s.id as status_id,
790 ActiveRecord::Base.connection.select_all("select s.id as status_id,
790 s.is_closed as closed,
791 s.is_closed as closed,
791 #{Issue.table_name}.project_id as project_id,
792 #{Issue.table_name}.project_id as project_id,
792 count(#{Issue.table_name}.id) as total
793 count(#{Issue.table_name}.id) as total
793 from
794 from
794 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
795 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
795 where
796 where
796 #{Issue.table_name}.status_id=s.id
797 #{Issue.table_name}.status_id=s.id
797 and #{Issue.table_name}.project_id = #{Project.table_name}.id
798 and #{Issue.table_name}.project_id = #{Project.table_name}.id
798 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
799 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
799 and #{Issue.table_name}.project_id <> #{project.id}
800 and #{Issue.table_name}.project_id <> #{project.id}
800 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
801 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
801 end
802 end
802 # End ReportsController extraction
803 # End ReportsController extraction
803
804
804 # Returns an array of projects that user can assign the issue to
805 # Returns an array of projects that user can assign the issue to
805 def allowed_target_projects(user=User.current)
806 def allowed_target_projects(user=User.current)
806 if new_record?
807 if new_record?
807 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
808 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
808 else
809 else
809 self.class.allowed_target_projects_on_move(user)
810 self.class.allowed_target_projects_on_move(user)
810 end
811 end
811 end
812 end
812
813
813 # Returns an array of projects that user can move issues to
814 # Returns an array of projects that user can move issues to
814 def self.allowed_target_projects_on_move(user=User.current)
815 def self.allowed_target_projects_on_move(user=User.current)
815 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
816 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
816 end
817 end
817
818
818 private
819 private
819
820
820 def after_project_change
821 def after_project_change
821 # Update project_id on related time entries
822 # Update project_id on related time entries
822 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
823 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
823
824
824 # Delete issue relations
825 # Delete issue relations
825 unless Setting.cross_project_issue_relations?
826 unless Setting.cross_project_issue_relations?
826 relations_from.clear
827 relations_from.clear
827 relations_to.clear
828 relations_to.clear
828 end
829 end
829
830
830 # Move subtasks
831 # Move subtasks
831 children.each do |child|
832 children.each do |child|
832 # Change project and keep project
833 # Change project and keep project
833 child.send :project=, project, true
834 child.send :project=, project, true
834 unless child.save
835 unless child.save
835 raise ActiveRecord::Rollback
836 raise ActiveRecord::Rollback
836 end
837 end
837 end
838 end
838 end
839 end
839
840
840 def update_nested_set_attributes
841 def update_nested_set_attributes
841 if root_id.nil?
842 if root_id.nil?
842 # issue was just created
843 # issue was just created
843 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
844 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
844 set_default_left_and_right
845 set_default_left_and_right
845 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
846 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
846 if @parent_issue
847 if @parent_issue
847 move_to_child_of(@parent_issue)
848 move_to_child_of(@parent_issue)
848 end
849 end
849 reload
850 reload
850 elsif parent_issue_id != parent_id
851 elsif parent_issue_id != parent_id
851 former_parent_id = parent_id
852 former_parent_id = parent_id
852 # moving an existing issue
853 # moving an existing issue
853 if @parent_issue && @parent_issue.root_id == root_id
854 if @parent_issue && @parent_issue.root_id == root_id
854 # inside the same tree
855 # inside the same tree
855 move_to_child_of(@parent_issue)
856 move_to_child_of(@parent_issue)
856 else
857 else
857 # to another tree
858 # to another tree
858 unless root?
859 unless root?
859 move_to_right_of(root)
860 move_to_right_of(root)
860 reload
861 reload
861 end
862 end
862 old_root_id = root_id
863 old_root_id = root_id
863 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
864 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
864 target_maxright = nested_set_scope.maximum(right_column_name) || 0
865 target_maxright = nested_set_scope.maximum(right_column_name) || 0
865 offset = target_maxright + 1 - lft
866 offset = target_maxright + 1 - lft
866 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
867 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
867 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
868 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
868 self[left_column_name] = lft + offset
869 self[left_column_name] = lft + offset
869 self[right_column_name] = rgt + offset
870 self[right_column_name] = rgt + offset
870 if @parent_issue
871 if @parent_issue
871 move_to_child_of(@parent_issue)
872 move_to_child_of(@parent_issue)
872 end
873 end
873 end
874 end
874 reload
875 reload
875 # delete invalid relations of all descendants
876 # delete invalid relations of all descendants
876 self_and_descendants.each do |issue|
877 self_and_descendants.each do |issue|
877 issue.relations.each do |relation|
878 issue.relations.each do |relation|
878 relation.destroy unless relation.valid?
879 relation.destroy unless relation.valid?
879 end
880 end
880 end
881 end
881 # update former parent
882 # update former parent
882 recalculate_attributes_for(former_parent_id) if former_parent_id
883 recalculate_attributes_for(former_parent_id) if former_parent_id
883 end
884 end
884 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
885 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
885 end
886 end
886
887
887 def update_parent_attributes
888 def update_parent_attributes
888 recalculate_attributes_for(parent_id) if parent_id
889 recalculate_attributes_for(parent_id) if parent_id
889 end
890 end
890
891
891 def recalculate_attributes_for(issue_id)
892 def recalculate_attributes_for(issue_id)
892 if issue_id && p = Issue.find_by_id(issue_id)
893 if issue_id && p = Issue.find_by_id(issue_id)
893 # priority = highest priority of children
894 # priority = highest priority of children
894 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
895 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
895 p.priority = IssuePriority.find_by_position(priority_position)
896 p.priority = IssuePriority.find_by_position(priority_position)
896 end
897 end
897
898
898 # start/due dates = lowest/highest dates of children
899 # start/due dates = lowest/highest dates of children
899 p.start_date = p.children.minimum(:start_date)
900 p.start_date = p.children.minimum(:start_date)
900 p.due_date = p.children.maximum(:due_date)
901 p.due_date = p.children.maximum(:due_date)
901 if p.start_date && p.due_date && p.due_date < p.start_date
902 if p.start_date && p.due_date && p.due_date < p.start_date
902 p.start_date, p.due_date = p.due_date, p.start_date
903 p.start_date, p.due_date = p.due_date, p.start_date
903 end
904 end
904
905
905 # done ratio = weighted average ratio of leaves
906 # done ratio = weighted average ratio of leaves
906 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
907 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
907 leaves_count = p.leaves.count
908 leaves_count = p.leaves.count
908 if leaves_count > 0
909 if leaves_count > 0
909 average = p.leaves.average(:estimated_hours).to_f
910 average = p.leaves.average(:estimated_hours).to_f
910 if average == 0
911 if average == 0
911 average = 1
912 average = 1
912 end
913 end
913 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
914 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
914 progress = done / (average * leaves_count)
915 progress = done / (average * leaves_count)
915 p.done_ratio = progress.round
916 p.done_ratio = progress.round
916 end
917 end
917 end
918 end
918
919
919 # estimate = sum of leaves estimates
920 # estimate = sum of leaves estimates
920 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
921 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
921 p.estimated_hours = nil if p.estimated_hours == 0.0
922 p.estimated_hours = nil if p.estimated_hours == 0.0
922
923
923 # ancestors will be recursively updated
924 # ancestors will be recursively updated
924 p.save(:validate => false)
925 p.save(:validate => false)
925 end
926 end
926 end
927 end
927
928
928 # Update issues so their versions are not pointing to a
929 # Update issues so their versions are not pointing to a
929 # fixed_version that is not shared with the issue's project
930 # fixed_version that is not shared with the issue's project
930 def self.update_versions(conditions=nil)
931 def self.update_versions(conditions=nil)
931 # Only need to update issues with a fixed_version from
932 # Only need to update issues with a fixed_version from
932 # a different project and that is not systemwide shared
933 # a different project and that is not systemwide shared
933 Issue.scoped(:conditions => conditions).all(
934 Issue.scoped(:conditions => conditions).all(
934 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
935 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
935 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
936 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
936 " AND #{Version.table_name}.sharing <> 'system'",
937 " AND #{Version.table_name}.sharing <> 'system'",
937 :include => [:project, :fixed_version]
938 :include => [:project, :fixed_version]
938 ).each do |issue|
939 ).each do |issue|
939 next if issue.project.nil? || issue.fixed_version.nil?
940 next if issue.project.nil? || issue.fixed_version.nil?
940 unless issue.project.shared_versions.include?(issue.fixed_version)
941 unless issue.project.shared_versions.include?(issue.fixed_version)
941 issue.init_journal(User.current)
942 issue.init_journal(User.current)
942 issue.fixed_version = nil
943 issue.fixed_version = nil
943 issue.save
944 issue.save
944 end
945 end
945 end
946 end
946 end
947 end
947
948
948 # Callback on attachment deletion
949 # Callback on attachment deletion
949 def attachment_added(obj)
950 def attachment_added(obj)
950 if @current_journal && !obj.new_record?
951 if @current_journal && !obj.new_record?
951 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
952 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
952 end
953 end
953 end
954 end
954
955
955 # Callback on attachment deletion
956 # Callback on attachment deletion
956 def attachment_removed(obj)
957 def attachment_removed(obj)
957 if @current_journal && !obj.new_record?
958 if @current_journal && !obj.new_record?
958 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
959 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
959 @current_journal.save
960 @current_journal.save
960 end
961 end
961 end
962 end
962
963
963 # Default assignment based on category
964 # Default assignment based on category
964 def default_assign
965 def default_assign
965 if assigned_to.nil? && category && category.assigned_to
966 if assigned_to.nil? && category && category.assigned_to
966 self.assigned_to = category.assigned_to
967 self.assigned_to = category.assigned_to
967 end
968 end
968 end
969 end
969
970
970 # Updates start/due dates of following issues
971 # Updates start/due dates of following issues
971 def reschedule_following_issues
972 def reschedule_following_issues
972 if start_date_changed? || due_date_changed?
973 if start_date_changed? || due_date_changed?
973 relations_from.each do |relation|
974 relations_from.each do |relation|
974 relation.set_issue_to_dates
975 relation.set_issue_to_dates
975 end
976 end
976 end
977 end
977 end
978 end
978
979
979 # Closes duplicates if the issue is being closed
980 # Closes duplicates if the issue is being closed
980 def close_duplicates
981 def close_duplicates
981 if closing?
982 if closing?
982 duplicates.each do |duplicate|
983 duplicates.each do |duplicate|
983 # Reload is need in case the duplicate was updated by a previous duplicate
984 # Reload is need in case the duplicate was updated by a previous duplicate
984 duplicate.reload
985 duplicate.reload
985 # Don't re-close it if it's already closed
986 # Don't re-close it if it's already closed
986 next if duplicate.closed?
987 next if duplicate.closed?
987 # Same user and notes
988 # Same user and notes
988 if @current_journal
989 if @current_journal
989 duplicate.init_journal(@current_journal.user, @current_journal.notes)
990 duplicate.init_journal(@current_journal.user, @current_journal.notes)
990 end
991 end
991 duplicate.update_attribute :status, self.status
992 duplicate.update_attribute :status, self.status
992 end
993 end
993 end
994 end
994 end
995 end
995
996
996 # Saves the changes in a Journal
997 # Saves the changes in a Journal
997 # Called after_save
998 # Called after_save
998 def create_journal
999 def create_journal
999 if @current_journal
1000 if @current_journal
1000 # attributes changes
1001 # attributes changes
1001 if @attributes_before_change
1002 if @attributes_before_change
1002 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1003 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1003 before = @attributes_before_change[c]
1004 before = @attributes_before_change[c]
1004 after = send(c)
1005 after = send(c)
1005 next if before == after || (before.blank? && after.blank?)
1006 next if before == after || (before.blank? && after.blank?)
1006 @current_journal.details << JournalDetail.new(:property => 'attr',
1007 @current_journal.details << JournalDetail.new(:property => 'attr',
1007 :prop_key => c,
1008 :prop_key => c,
1008 :old_value => before,
1009 :old_value => before,
1009 :value => after)
1010 :value => after)
1010 }
1011 }
1011 end
1012 end
1012 if @custom_values_before_change
1013 if @custom_values_before_change
1013 # custom fields changes
1014 # custom fields changes
1014 custom_field_values.each {|c|
1015 custom_field_values.each {|c|
1015 before = @custom_values_before_change[c.custom_field_id]
1016 before = @custom_values_before_change[c.custom_field_id]
1016 after = c.value
1017 after = c.value
1017 next if before == after || (before.blank? && after.blank?)
1018 next if before == after || (before.blank? && after.blank?)
1018
1019
1019 if before.is_a?(Array) || after.is_a?(Array)
1020 if before.is_a?(Array) || after.is_a?(Array)
1020 before = [before] unless before.is_a?(Array)
1021 before = [before] unless before.is_a?(Array)
1021 after = [after] unless after.is_a?(Array)
1022 after = [after] unless after.is_a?(Array)
1022
1023
1023 # values removed
1024 # values removed
1024 (before - after).reject(&:blank?).each do |value|
1025 (before - after).reject(&:blank?).each do |value|
1025 @current_journal.details << JournalDetail.new(:property => 'cf',
1026 @current_journal.details << JournalDetail.new(:property => 'cf',
1026 :prop_key => c.custom_field_id,
1027 :prop_key => c.custom_field_id,
1027 :old_value => value,
1028 :old_value => value,
1028 :value => nil)
1029 :value => nil)
1029 end
1030 end
1030 # values added
1031 # values added
1031 (after - before).reject(&:blank?).each do |value|
1032 (after - before).reject(&:blank?).each do |value|
1032 @current_journal.details << JournalDetail.new(:property => 'cf',
1033 @current_journal.details << JournalDetail.new(:property => 'cf',
1033 :prop_key => c.custom_field_id,
1034 :prop_key => c.custom_field_id,
1034 :old_value => nil,
1035 :old_value => nil,
1035 :value => value)
1036 :value => value)
1036 end
1037 end
1037 else
1038 else
1038 @current_journal.details << JournalDetail.new(:property => 'cf',
1039 @current_journal.details << JournalDetail.new(:property => 'cf',
1039 :prop_key => c.custom_field_id,
1040 :prop_key => c.custom_field_id,
1040 :old_value => before,
1041 :old_value => before,
1041 :value => after)
1042 :value => after)
1042 end
1043 end
1043 }
1044 }
1044 end
1045 end
1045 @current_journal.save
1046 @current_journal.save
1046 # reset current journal
1047 # reset current journal
1047 init_journal @current_journal.user, @current_journal.notes
1048 init_journal @current_journal.user, @current_journal.notes
1048 end
1049 end
1049 end
1050 end
1050
1051
1051 # Query generator for selecting groups of issue counts for a project
1052 # Query generator for selecting groups of issue counts for a project
1052 # based on specific criteria
1053 # based on specific criteria
1053 #
1054 #
1054 # Options
1055 # Options
1055 # * project - Project to search in.
1056 # * project - Project to search in.
1056 # * field - String. Issue field to key off of in the grouping.
1057 # * field - String. Issue field to key off of in the grouping.
1057 # * joins - String. The table name to join against.
1058 # * joins - String. The table name to join against.
1058 def self.count_and_group_by(options)
1059 def self.count_and_group_by(options)
1059 project = options.delete(:project)
1060 project = options.delete(:project)
1060 select_field = options.delete(:field)
1061 select_field = options.delete(:field)
1061 joins = options.delete(:joins)
1062 joins = options.delete(:joins)
1062
1063
1063 where = "#{Issue.table_name}.#{select_field}=j.id"
1064 where = "#{Issue.table_name}.#{select_field}=j.id"
1064
1065
1065 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1066 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1066 s.is_closed as closed,
1067 s.is_closed as closed,
1067 j.id as #{select_field},
1068 j.id as #{select_field},
1068 count(#{Issue.table_name}.id) as total
1069 count(#{Issue.table_name}.id) as total
1069 from
1070 from
1070 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1071 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1071 where
1072 where
1072 #{Issue.table_name}.status_id=s.id
1073 #{Issue.table_name}.status_id=s.id
1073 and #{where}
1074 and #{where}
1074 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1075 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1075 and #{visible_condition(User.current, :project => project)}
1076 and #{visible_condition(User.current, :project => project)}
1076 group by s.id, s.is_closed, j.id")
1077 group by s.id, s.is_closed, j.id")
1077 end
1078 end
1078 end
1079 end
@@ -1,48 +1,48
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssueCategory < ActiveRecord::Base
18 class IssueCategory < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 belongs_to :project
20 belongs_to :project
21 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
21 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
22 has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
22 has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
23
23
24 validates_presence_of :name
24 validates_presence_of :name
25 validates_uniqueness_of :name, :scope => [:project_id]
25 validates_uniqueness_of :name, :scope => [:project_id]
26 validates_length_of :name, :maximum => 30
26 validates_length_of :name, :maximum => 30
27
27
28 safe_attributes 'name', 'assigned_to_id'
28 safe_attributes 'name', 'assigned_to_id'
29
29
30 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
30 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
31
31
32 alias :destroy_without_reassign :destroy
32 alias :destroy_without_reassign :destroy
33
33
34 # Destroy the category
34 # Destroy the category
35 # If a category is specified, issues are reassigned to this category
35 # If a category is specified, issues are reassigned to this category
36 def destroy(reassign_to = nil)
36 def destroy(reassign_to = nil)
37 if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
37 if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
38 Issue.update_all("category_id = #{reassign_to.id}", "category_id = #{id}")
38 Issue.update_all("category_id = #{reassign_to.id}", "category_id = #{id}")
39 end
39 end
40 destroy_without_reassign
40 destroy_without_reassign
41 end
41 end
42
42
43 def <=>(category)
43 def <=>(category)
44 name <=> category.name
44 name <=> category.name
45 end
45 end
46
46
47 def to_s; name end
47 def to_s; name end
48 end
48 end
@@ -1,104 +1,104
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssueStatus < ActiveRecord::Base
18 class IssueStatus < ActiveRecord::Base
19 before_destroy :check_integrity
19 before_destroy :check_integrity
20 has_many :workflows, :foreign_key => "old_status_id"
20 has_many :workflows, :foreign_key => "old_status_id"
21 acts_as_list
21 acts_as_list
22
22
23 before_destroy :delete_workflows
23 before_destroy :delete_workflows
24 after_save :update_default
24 after_save :update_default
25
25
26 validates_presence_of :name
26 validates_presence_of :name
27 validates_uniqueness_of :name
27 validates_uniqueness_of :name
28 validates_length_of :name, :maximum => 30
28 validates_length_of :name, :maximum => 30
29 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
29 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
30
30
31 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
31 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
32
32
33 def update_default
33 def update_default
34 IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default?
34 IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default?
35 end
35 end
36
36
37 # Returns the default status for new issues
37 # Returns the default status for new issues
38 def self.default
38 def self.default
39 find(:first, :conditions =>["is_default=?", true])
39 find(:first, :conditions =>["is_default=?", true])
40 end
40 end
41
41
42 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
42 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
43 def self.update_issue_done_ratios
43 def self.update_issue_done_ratios
44 if Issue.use_status_for_done_ratio?
44 if Issue.use_status_for_done_ratio?
45 IssueStatus.find(:all, :conditions => ["default_done_ratio >= 0"]).each do |status|
45 IssueStatus.find(:all, :conditions => ["default_done_ratio >= 0"]).each do |status|
46 Issue.update_all(["done_ratio = ?", status.default_done_ratio],
46 Issue.update_all(["done_ratio = ?", status.default_done_ratio],
47 ["status_id = ?", status.id])
47 ["status_id = ?", status.id])
48 end
48 end
49 end
49 end
50
50
51 return Issue.use_status_for_done_ratio?
51 return Issue.use_status_for_done_ratio?
52 end
52 end
53
53
54 # Returns an array of all statuses the given role can switch to
54 # Returns an array of all statuses the given role can switch to
55 # Uses association cache when called more than one time
55 # Uses association cache when called more than one time
56 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
56 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
57 if roles && tracker
57 if roles && tracker
58 role_ids = roles.collect(&:id)
58 role_ids = roles.collect(&:id)
59 transitions = workflows.select do |w|
59 transitions = workflows.select do |w|
60 role_ids.include?(w.role_id) &&
60 role_ids.include?(w.role_id) &&
61 w.tracker_id == tracker.id &&
61 w.tracker_id == tracker.id &&
62 ((!w.author && !w.assignee) || (author && w.author) || (assignee && w.assignee))
62 ((!w.author && !w.assignee) || (author && w.author) || (assignee && w.assignee))
63 end
63 end
64 transitions.collect{|w| w.new_status}.compact.sort
64 transitions.collect{|w| w.new_status}.compact.sort
65 else
65 else
66 []
66 []
67 end
67 end
68 end
68 end
69
69
70 # Same thing as above but uses a database query
70 # Same thing as above but uses a database query
71 # More efficient than the previous method if called just once
71 # More efficient than the previous method if called just once
72 def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
72 def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
73 if roles.present? && tracker
73 if roles.present? && tracker
74 conditions = "(author = :false AND assignee = :false)"
74 conditions = "(author = :false AND assignee = :false)"
75 conditions << " OR author = :true" if author
75 conditions << " OR author = :true" if author
76 conditions << " OR assignee = :true" if assignee
76 conditions << " OR assignee = :true" if assignee
77
77
78 workflows.find(:all,
78 workflows.find(:all,
79 :include => :new_status,
79 :include => :new_status,
80 :conditions => ["role_id IN (:role_ids) AND tracker_id = :tracker_id AND (#{conditions})",
80 :conditions => ["role_id IN (:role_ids) AND tracker_id = :tracker_id AND (#{conditions})",
81 {:role_ids => roles.collect(&:id), :tracker_id => tracker.id, :true => true, :false => false}
81 {:role_ids => roles.collect(&:id), :tracker_id => tracker.id, :true => true, :false => false}
82 ]
82 ]
83 ).collect{|w| w.new_status}.compact.sort
83 ).collect{|w| w.new_status}.compact.sort
84 else
84 else
85 []
85 []
86 end
86 end
87 end
87 end
88
88
89 def <=>(status)
89 def <=>(status)
90 position <=> status.position
90 position <=> status.position
91 end
91 end
92
92
93 def to_s; name end
93 def to_s; name end
94
94
95 private
95 private
96 def check_integrity
96 def check_integrity
97 raise "Can't delete status" if Issue.find(:first, :conditions => ["status_id=?", self.id])
97 raise "Can't delete status" if Issue.find(:first, :conditions => ["status_id=?", self.id])
98 end
98 end
99
99
100 # Deletes associated workflows
100 # Deletes associated workflows
101 def delete_workflows
101 def delete_workflows
102 Workflow.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
102 Workflow.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
103 end
103 end
104 end
104 end
@@ -1,88 +1,88
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Journal < ActiveRecord::Base
18 class Journal < ActiveRecord::Base
19 belongs_to :journalized, :polymorphic => true
19 belongs_to :journalized, :polymorphic => true
20 # added as a quick fix to allow eager loading of the polymorphic association
20 # added as a quick fix to allow eager loading of the polymorphic association
21 # since always associated to an issue, for now
21 # since always associated to an issue, for now
22 belongs_to :issue, :foreign_key => :journalized_id
22 belongs_to :issue, :foreign_key => :journalized_id
23
23
24 belongs_to :user
24 belongs_to :user
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
26 attr_accessor :indice
26 attr_accessor :indice
27
27
28 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
28 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
29 :description => :notes,
29 :description => :notes,
30 :author => :user,
30 :author => :user,
31 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
31 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
32 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
32 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
33
33
34 acts_as_activity_provider :type => 'issues',
34 acts_as_activity_provider :type => 'issues',
35 :author_key => :user_id,
35 :author_key => :user_id,
36 :find_options => {:include => [{:issue => :project}, :details, :user],
36 :find_options => {:include => [{:issue => :project}, :details, :user],
37 :conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
37 :conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
38 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
38 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
39
39
40 named_scope :visible, lambda {|*args| {
40 scope :visible, lambda {|*args| {
41 :include => {:issue => :project},
41 :include => {:issue => :project},
42 :conditions => Issue.visible_condition(args.shift || User.current, *args)
42 :conditions => Issue.visible_condition(args.shift || User.current, *args)
43 }}
43 }}
44
44
45 def save(*args)
45 def save(*args)
46 # Do not save an empty journal
46 # Do not save an empty journal
47 (details.empty? && notes.blank?) ? false : super
47 (details.empty? && notes.blank?) ? false : super
48 end
48 end
49
49
50 # Returns the new status if the journal contains a status change, otherwise nil
50 # Returns the new status if the journal contains a status change, otherwise nil
51 def new_status
51 def new_status
52 c = details.detect {|detail| detail.prop_key == 'status_id'}
52 c = details.detect {|detail| detail.prop_key == 'status_id'}
53 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
53 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
54 end
54 end
55
55
56 def new_value_for(prop)
56 def new_value_for(prop)
57 c = details.detect {|detail| detail.prop_key == prop}
57 c = details.detect {|detail| detail.prop_key == prop}
58 c ? c.value : nil
58 c ? c.value : nil
59 end
59 end
60
60
61 def editable_by?(usr)
61 def editable_by?(usr)
62 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
62 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
63 end
63 end
64
64
65 def project
65 def project
66 journalized.respond_to?(:project) ? journalized.project : nil
66 journalized.respond_to?(:project) ? journalized.project : nil
67 end
67 end
68
68
69 def attachments
69 def attachments
70 journalized.respond_to?(:attachments) ? journalized.attachments : nil
70 journalized.respond_to?(:attachments) ? journalized.attachments : nil
71 end
71 end
72
72
73 # Returns a string of css classes
73 # Returns a string of css classes
74 def css_classes
74 def css_classes
75 s = 'journal'
75 s = 'journal'
76 s << ' has-notes' unless notes.blank?
76 s << ' has-notes' unless notes.blank?
77 s << ' has-details' unless details.blank?
77 s << ' has-details' unless details.blank?
78 s
78 s
79 end
79 end
80
80
81 def notify?
81 def notify?
82 @notify != false
82 @notify != false
83 end
83 end
84
84
85 def notify=(arg)
85 def notify=(arg)
86 @notify = arg
86 @notify = arg
87 end
87 end
88 end
88 end
@@ -1,110 +1,110
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Message < ActiveRecord::Base
18 class Message < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 belongs_to :board
20 belongs_to :board
21 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
21 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
22 acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
23 acts_as_attachable
23 acts_as_attachable
24 belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
24 belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
25
25
26 acts_as_searchable :columns => ['subject', 'content'],
26 acts_as_searchable :columns => ['subject', 'content'],
27 :include => {:board => :project},
27 :include => {:board => :project},
28 :project_key => "#{Board.table_name}.project_id",
28 :project_key => "#{Board.table_name}.project_id",
29 :date_column => "#{table_name}.created_on"
29 :date_column => "#{table_name}.created_on"
30 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
30 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
31 :description => :content,
31 :description => :content,
32 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
32 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
33 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
33 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
34 {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
34 {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
35
35
36 acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
36 acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
37 :author_key => :author_id
37 :author_key => :author_id
38 acts_as_watchable
38 acts_as_watchable
39
39
40 validates_presence_of :board, :subject, :content
40 validates_presence_of :board, :subject, :content
41 validates_length_of :subject, :maximum => 255
41 validates_length_of :subject, :maximum => 255
42 validate :cannot_reply_to_locked_topic, :on => :create
42 validate :cannot_reply_to_locked_topic, :on => :create
43
43
44 after_create :add_author_as_watcher, :update_parent_last_reply
44 after_create :add_author_as_watcher, :update_parent_last_reply
45 after_update :update_messages_board
45 after_update :update_messages_board
46 after_destroy :reset_board_counters
46 after_destroy :reset_board_counters
47
47
48 named_scope :visible, lambda {|*args| { :include => {:board => :project},
48 scope :visible, lambda {|*args| { :include => {:board => :project},
49 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
49 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
50
50
51 safe_attributes 'subject', 'content'
51 safe_attributes 'subject', 'content'
52 safe_attributes 'locked', 'sticky', 'board_id',
52 safe_attributes 'locked', 'sticky', 'board_id',
53 :if => lambda {|message, user|
53 :if => lambda {|message, user|
54 user.allowed_to?(:edit_messages, message.project)
54 user.allowed_to?(:edit_messages, message.project)
55 }
55 }
56
56
57 def visible?(user=User.current)
57 def visible?(user=User.current)
58 !user.nil? && user.allowed_to?(:view_messages, project)
58 !user.nil? && user.allowed_to?(:view_messages, project)
59 end
59 end
60
60
61 def cannot_reply_to_locked_topic
61 def cannot_reply_to_locked_topic
62 # Can not reply to a locked topic
62 # Can not reply to a locked topic
63 errors.add :base, 'Topic is locked' if root.locked? && self != root
63 errors.add :base, 'Topic is locked' if root.locked? && self != root
64 end
64 end
65
65
66 def update_parent_last_reply
66 def update_parent_last_reply
67 if parent
67 if parent
68 parent.reload.update_attribute(:last_reply_id, self.id)
68 parent.reload.update_attribute(:last_reply_id, self.id)
69 end
69 end
70 board.reset_counters!
70 board.reset_counters!
71 end
71 end
72
72
73 def update_messages_board
73 def update_messages_board
74 if board_id_changed?
74 if board_id_changed?
75 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
75 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
76 Board.reset_counters!(board_id_was)
76 Board.reset_counters!(board_id_was)
77 Board.reset_counters!(board_id)
77 Board.reset_counters!(board_id)
78 end
78 end
79 end
79 end
80
80
81 def reset_board_counters
81 def reset_board_counters
82 board.reset_counters!
82 board.reset_counters!
83 end
83 end
84
84
85 def sticky=(arg)
85 def sticky=(arg)
86 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
86 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
87 end
87 end
88
88
89 def sticky?
89 def sticky?
90 sticky == 1
90 sticky == 1
91 end
91 end
92
92
93 def project
93 def project
94 board.project
94 board.project
95 end
95 end
96
96
97 def editable_by?(usr)
97 def editable_by?(usr)
98 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
98 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
99 end
99 end
100
100
101 def destroyable_by?(usr)
101 def destroyable_by?(usr)
102 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
102 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
103 end
103 end
104
104
105 private
105 private
106
106
107 def add_author_as_watcher
107 def add_author_as_watcher
108 Watcher.create(:watchable => self.root, :user => author)
108 Watcher.create(:watchable => self.root, :user => author)
109 end
109 end
110 end
110 end
@@ -1,66 +1,66
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class News < ActiveRecord::Base
18 class News < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 belongs_to :project
20 belongs_to :project
21 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
21 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on"
22 has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on"
23
23
24 validates_presence_of :title, :description
24 validates_presence_of :title, :description
25 validates_length_of :title, :maximum => 60
25 validates_length_of :title, :maximum => 60
26 validates_length_of :summary, :maximum => 255
26 validates_length_of :summary, :maximum => 255
27
27
28 acts_as_attachable :delete_permission => :manage_news
28 acts_as_attachable :delete_permission => :manage_news
29 acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
29 acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
30 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
30 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
31 acts_as_activity_provider :find_options => {:include => [:project, :author]},
31 acts_as_activity_provider :find_options => {:include => [:project, :author]},
32 :author_key => :author_id
32 :author_key => :author_id
33 acts_as_watchable
33 acts_as_watchable
34
34
35 after_create :add_author_as_watcher
35 after_create :add_author_as_watcher
36
36
37 named_scope :visible, lambda {|*args| {
37 scope :visible, lambda {|*args| {
38 :include => :project,
38 :include => :project,
39 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_news, *args)
39 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_news, *args)
40 }}
40 }}
41
41
42 safe_attributes 'title', 'summary', 'description'
42 safe_attributes 'title', 'summary', 'description'
43
43
44 def visible?(user=User.current)
44 def visible?(user=User.current)
45 !user.nil? && user.allowed_to?(:view_news, project)
45 !user.nil? && user.allowed_to?(:view_news, project)
46 end
46 end
47
47
48 # Returns true if the news can be commented by user
48 # Returns true if the news can be commented by user
49 def commentable?(user=User.current)
49 def commentable?(user=User.current)
50 user.allowed_to?(:comment_news, project)
50 user.allowed_to?(:comment_news, project)
51 end
51 end
52
52
53 # returns latest news for projects visible by user
53 # returns latest news for projects visible by user
54 def self.latest(user = User.current, count = 5)
54 def self.latest(user = User.current, count = 5)
55 find(:all, :limit => count,
55 find(:all, :limit => count,
56 :conditions => Project.allowed_to_condition(user, :view_news),
56 :conditions => Project.allowed_to_condition(user, :view_news),
57 :include => [ :author, :project ],
57 :include => [ :author, :project ],
58 :order => "#{News.table_name}.created_on DESC")
58 :order => "#{News.table_name}.created_on DESC")
59 end
59 end
60
60
61 private
61 private
62
62
63 def add_author_as_watcher
63 def add_author_as_watcher
64 Watcher.create(:watchable => self, :user => author)
64 Watcher.create(:watchable => self, :user => author)
65 end
65 end
66 end
66 end
@@ -1,95 +1,95
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Principal < ActiveRecord::Base
18 class Principal < ActiveRecord::Base
19 set_table_name "#{table_name_prefix}users#{table_name_suffix}"
19 set_table_name "#{table_name_prefix}users#{table_name_suffix}"
20
20
21 has_many :members, :foreign_key => 'user_id', :dependent => :destroy
21 has_many :members, :foreign_key => 'user_id', :dependent => :destroy
22 has_many :memberships, :class_name => 'Member', :foreign_key => 'user_id', :include => [ :project, :roles ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name"
22 has_many :memberships, :class_name => 'Member', :foreign_key => 'user_id', :include => [ :project, :roles ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name"
23 has_many :projects, :through => :memberships
23 has_many :projects, :through => :memberships
24 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
24 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
25
25
26 # Groups and active users
26 # Groups and active users
27 named_scope :active, :conditions => "#{Principal.table_name}.status = 1"
27 scope :active, :conditions => "#{Principal.table_name}.status = 1"
28
28
29 named_scope :like, lambda {|q|
29 scope :like, lambda {|q|
30 if q.blank?
30 if q.blank?
31 {}
31 {}
32 else
32 else
33 q = q.to_s.downcase
33 q = q.to_s.downcase
34 pattern = "%#{q}%"
34 pattern = "%#{q}%"
35 sql = "LOWER(login) LIKE :p OR LOWER(firstname) LIKE :p OR LOWER(lastname) LIKE :p OR LOWER(mail) LIKE :p"
35 sql = "LOWER(login) LIKE :p OR LOWER(firstname) LIKE :p OR LOWER(lastname) LIKE :p OR LOWER(mail) LIKE :p"
36 params = {:p => pattern}
36 params = {:p => pattern}
37 if q =~ /^(.+)\s+(.+)$/
37 if q =~ /^(.+)\s+(.+)$/
38 a, b = "#{$1}%", "#{$2}%"
38 a, b = "#{$1}%", "#{$2}%"
39 sql << " OR (LOWER(firstname) LIKE :a AND LOWER(lastname) LIKE :b) OR (LOWER(firstname) LIKE :b AND LOWER(lastname) LIKE :a)"
39 sql << " OR (LOWER(firstname) LIKE :a AND LOWER(lastname) LIKE :b) OR (LOWER(firstname) LIKE :b AND LOWER(lastname) LIKE :a)"
40 params.merge!(:a => a, :b => b)
40 params.merge!(:a => a, :b => b)
41 end
41 end
42 {:conditions => [sql, params]}
42 {:conditions => [sql, params]}
43 end
43 end
44 }
44 }
45
45
46 # Principals that are members of a collection of projects
46 # Principals that are members of a collection of projects
47 named_scope :member_of, lambda {|projects|
47 scope :member_of, lambda {|projects|
48 projects = [projects] unless projects.is_a?(Array)
48 projects = [projects] unless projects.is_a?(Array)
49 if projects.empty?
49 if projects.empty?
50 {:conditions => "1=0"}
50 {:conditions => "1=0"}
51 else
51 else
52 ids = projects.map(&:id)
52 ids = projects.map(&:id)
53 {:conditions => ["#{Principal.table_name}.status = 1 AND #{Principal.table_name}.id IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids]}
53 {:conditions => ["#{Principal.table_name}.status = 1 AND #{Principal.table_name}.id IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids]}
54 end
54 end
55 }
55 }
56 # Principals that are not members of projects
56 # Principals that are not members of projects
57 named_scope :not_member_of, lambda {|projects|
57 scope :not_member_of, lambda {|projects|
58 projects = [projects] unless projects.is_a?(Array)
58 projects = [projects] unless projects.is_a?(Array)
59 if projects.empty?
59 if projects.empty?
60 {:conditions => "1=0"}
60 {:conditions => "1=0"}
61 else
61 else
62 ids = projects.map(&:id)
62 ids = projects.map(&:id)
63 {:conditions => ["#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids]}
63 {:conditions => ["#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids]}
64 end
64 end
65 }
65 }
66
66
67 before_create :set_default_empty_values
67 before_create :set_default_empty_values
68
68
69 def name(formatter = nil)
69 def name(formatter = nil)
70 to_s
70 to_s
71 end
71 end
72
72
73 def <=>(principal)
73 def <=>(principal)
74 if principal.nil?
74 if principal.nil?
75 -1
75 -1
76 elsif self.class.name == principal.class.name
76 elsif self.class.name == principal.class.name
77 self.to_s.downcase <=> principal.to_s.downcase
77 self.to_s.downcase <=> principal.to_s.downcase
78 else
78 else
79 # groups after users
79 # groups after users
80 principal.class.name <=> self.class.name
80 principal.class.name <=> self.class.name
81 end
81 end
82 end
82 end
83
83
84 protected
84 protected
85
85
86 # Make sure we don't try to insert NULL values (see #4632)
86 # Make sure we don't try to insert NULL values (see #4632)
87 def set_default_empty_values
87 def set_default_empty_values
88 self.login ||= ''
88 self.login ||= ''
89 self.hashed_password ||= ''
89 self.hashed_password ||= ''
90 self.firstname ||= ''
90 self.firstname ||= ''
91 self.lastname ||= ''
91 self.lastname ||= ''
92 self.mail ||= ''
92 self.mail ||= ''
93 true
93 true
94 end
94 end
95 end
95 end
@@ -1,922 +1,922
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Project < ActiveRecord::Base
18 class Project < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 # Project statuses
21 # Project statuses
22 STATUS_ACTIVE = 1
22 STATUS_ACTIVE = 1
23 STATUS_ARCHIVED = 9
23 STATUS_ARCHIVED = 9
24
24
25 # Maximum length for project identifiers
25 # Maximum length for project identifiers
26 IDENTIFIER_MAX_LENGTH = 100
26 IDENTIFIER_MAX_LENGTH = 100
27
27
28 # Specific overidden Activities
28 # Specific overidden Activities
29 has_many :time_entry_activities
29 has_many :time_entry_activities
30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
31 has_many :memberships, :class_name => 'Member'
31 has_many :memberships, :class_name => 'Member'
32 has_many :member_principals, :class_name => 'Member',
32 has_many :member_principals, :class_name => 'Member',
33 :include => :principal,
33 :include => :principal,
34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
35 has_many :users, :through => :members
35 has_many :users, :through => :members
36 has_many :principals, :through => :member_principals, :source => :principal
36 has_many :principals, :through => :member_principals, :source => :principal
37
37
38 has_many :enabled_modules, :dependent => :delete_all
38 has_many :enabled_modules, :dependent => :delete_all
39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
40 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
40 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
41 has_many :issue_changes, :through => :issues, :source => :journals
41 has_many :issue_changes, :through => :issues, :source => :journals
42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
43 has_many :time_entries, :dependent => :delete_all
43 has_many :time_entries, :dependent => :delete_all
44 has_many :queries, :dependent => :delete_all
44 has_many :queries, :dependent => :delete_all
45 has_many :documents, :dependent => :destroy
45 has_many :documents, :dependent => :destroy
46 has_many :news, :dependent => :destroy, :include => :author
46 has_many :news, :dependent => :destroy, :include => :author
47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
48 has_many :boards, :dependent => :destroy, :order => "position ASC"
48 has_many :boards, :dependent => :destroy, :order => "position ASC"
49 has_one :repository, :conditions => ["is_default = ?", true]
49 has_one :repository, :conditions => ["is_default = ?", true]
50 has_many :repositories, :dependent => :destroy
50 has_many :repositories, :dependent => :destroy
51 has_many :changesets, :through => :repository
51 has_many :changesets, :through => :repository
52 has_one :wiki, :dependent => :destroy
52 has_one :wiki, :dependent => :destroy
53 # Custom field for the project issues
53 # Custom field for the project issues
54 has_and_belongs_to_many :issue_custom_fields,
54 has_and_belongs_to_many :issue_custom_fields,
55 :class_name => 'IssueCustomField',
55 :class_name => 'IssueCustomField',
56 :order => "#{CustomField.table_name}.position",
56 :order => "#{CustomField.table_name}.position",
57 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
58 :association_foreign_key => 'custom_field_id'
58 :association_foreign_key => 'custom_field_id'
59
59
60 acts_as_nested_set :order => 'name', :dependent => :destroy
60 acts_as_nested_set :order => 'name', :dependent => :destroy
61 acts_as_attachable :view_permission => :view_files,
61 acts_as_attachable :view_permission => :view_files,
62 :delete_permission => :manage_files
62 :delete_permission => :manage_files
63
63
64 acts_as_customizable
64 acts_as_customizable
65 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
65 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
66 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
67 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
68 :author => nil
68 :author => nil
69
69
70 attr_protected :status
70 attr_protected :status
71
71
72 validates_presence_of :name, :identifier
72 validates_presence_of :name, :identifier
73 validates_uniqueness_of :identifier
73 validates_uniqueness_of :identifier
74 validates_associated :repository, :wiki
74 validates_associated :repository, :wiki
75 validates_length_of :name, :maximum => 255
75 validates_length_of :name, :maximum => 255
76 validates_length_of :homepage, :maximum => 255
76 validates_length_of :homepage, :maximum => 255
77 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
77 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
78 # donwcase letters, digits, dashes but not digits only
78 # donwcase letters, digits, dashes but not digits only
79 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
79 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
80 # reserved words
80 # reserved words
81 validates_exclusion_of :identifier, :in => %w( new )
81 validates_exclusion_of :identifier, :in => %w( new )
82
82
83 before_destroy :delete_all_members
83 before_destroy :delete_all_members
84
84
85 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
85 scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
86 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
86 scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
87 named_scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
87 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
88 named_scope :all_public, { :conditions => { :is_public => true } }
88 scope :all_public, { :conditions => { :is_public => true } }
89 named_scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
89 scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
90 named_scope :allowed_to, lambda {|*args|
90 scope :allowed_to, lambda {|*args|
91 user = User.current
91 user = User.current
92 permission = nil
92 permission = nil
93 if args.first.is_a?(Symbol)
93 if args.first.is_a?(Symbol)
94 permission = args.shift
94 permission = args.shift
95 else
95 else
96 user = args.shift
96 user = args.shift
97 permission = args.shift
97 permission = args.shift
98 end
98 end
99 { :conditions => Project.allowed_to_condition(user, permission, *args) }
99 { :conditions => Project.allowed_to_condition(user, permission, *args) }
100 }
100 }
101 named_scope :like, lambda {|arg|
101 scope :like, lambda {|arg|
102 if arg.blank?
102 if arg.blank?
103 {}
103 {}
104 else
104 else
105 pattern = "%#{arg.to_s.strip.downcase}%"
105 pattern = "%#{arg.to_s.strip.downcase}%"
106 {:conditions => ["LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", {:p => pattern}]}
106 {:conditions => ["LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", {:p => pattern}]}
107 end
107 end
108 }
108 }
109
109
110 def initialize(attributes=nil, *args)
110 def initialize(attributes=nil, *args)
111 super
111 super
112
112
113 initialized = (attributes || {}).stringify_keys
113 initialized = (attributes || {}).stringify_keys
114 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
114 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
115 self.identifier = Project.next_identifier
115 self.identifier = Project.next_identifier
116 end
116 end
117 if !initialized.key?('is_public')
117 if !initialized.key?('is_public')
118 self.is_public = Setting.default_projects_public?
118 self.is_public = Setting.default_projects_public?
119 end
119 end
120 if !initialized.key?('enabled_module_names')
120 if !initialized.key?('enabled_module_names')
121 self.enabled_module_names = Setting.default_projects_modules
121 self.enabled_module_names = Setting.default_projects_modules
122 end
122 end
123 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
123 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
124 self.trackers = Tracker.all
124 self.trackers = Tracker.all
125 end
125 end
126 end
126 end
127
127
128 def identifier=(identifier)
128 def identifier=(identifier)
129 super unless identifier_frozen?
129 super unless identifier_frozen?
130 end
130 end
131
131
132 def identifier_frozen?
132 def identifier_frozen?
133 errors[:identifier].nil? && !(new_record? || identifier.blank?)
133 errors[:identifier].nil? && !(new_record? || identifier.blank?)
134 end
134 end
135
135
136 # returns latest created projects
136 # returns latest created projects
137 # non public projects will be returned only if user is a member of those
137 # non public projects will be returned only if user is a member of those
138 def self.latest(user=nil, count=5)
138 def self.latest(user=nil, count=5)
139 visible(user).find(:all, :limit => count, :order => "created_on DESC")
139 visible(user).find(:all, :limit => count, :order => "created_on DESC")
140 end
140 end
141
141
142 # Returns true if the project is visible to +user+ or to the current user.
142 # Returns true if the project is visible to +user+ or to the current user.
143 def visible?(user=User.current)
143 def visible?(user=User.current)
144 user.allowed_to?(:view_project, self)
144 user.allowed_to?(:view_project, self)
145 end
145 end
146
146
147 # Returns a SQL conditions string used to find all projects visible by the specified user.
147 # Returns a SQL conditions string used to find all projects visible by the specified user.
148 #
148 #
149 # Examples:
149 # Examples:
150 # Project.visible_condition(admin) => "projects.status = 1"
150 # Project.visible_condition(admin) => "projects.status = 1"
151 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
151 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
152 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
152 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
153 def self.visible_condition(user, options={})
153 def self.visible_condition(user, options={})
154 allowed_to_condition(user, :view_project, options)
154 allowed_to_condition(user, :view_project, options)
155 end
155 end
156
156
157 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
157 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
158 #
158 #
159 # Valid options:
159 # Valid options:
160 # * :project => limit the condition to project
160 # * :project => limit the condition to project
161 # * :with_subprojects => limit the condition to project and its subprojects
161 # * :with_subprojects => limit the condition to project and its subprojects
162 # * :member => limit the condition to the user projects
162 # * :member => limit the condition to the user projects
163 def self.allowed_to_condition(user, permission, options={})
163 def self.allowed_to_condition(user, permission, options={})
164 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
164 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
165 if perm = Redmine::AccessControl.permission(permission)
165 if perm = Redmine::AccessControl.permission(permission)
166 unless perm.project_module.nil?
166 unless perm.project_module.nil?
167 # If the permission belongs to a project module, make sure the module is enabled
167 # If the permission belongs to a project module, make sure the module is enabled
168 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
168 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
169 end
169 end
170 end
170 end
171 if options[:project]
171 if options[:project]
172 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
172 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
173 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
173 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
174 base_statement = "(#{project_statement}) AND (#{base_statement})"
174 base_statement = "(#{project_statement}) AND (#{base_statement})"
175 end
175 end
176
176
177 if user.admin?
177 if user.admin?
178 base_statement
178 base_statement
179 else
179 else
180 statement_by_role = {}
180 statement_by_role = {}
181 unless options[:member]
181 unless options[:member]
182 role = user.logged? ? Role.non_member : Role.anonymous
182 role = user.logged? ? Role.non_member : Role.anonymous
183 if role.allowed_to?(permission)
183 if role.allowed_to?(permission)
184 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
184 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
185 end
185 end
186 end
186 end
187 if user.logged?
187 if user.logged?
188 user.projects_by_role.each do |role, projects|
188 user.projects_by_role.each do |role, projects|
189 if role.allowed_to?(permission)
189 if role.allowed_to?(permission)
190 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
190 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
191 end
191 end
192 end
192 end
193 end
193 end
194 if statement_by_role.empty?
194 if statement_by_role.empty?
195 "1=0"
195 "1=0"
196 else
196 else
197 if block_given?
197 if block_given?
198 statement_by_role.each do |role, statement|
198 statement_by_role.each do |role, statement|
199 if s = yield(role, user)
199 if s = yield(role, user)
200 statement_by_role[role] = "(#{statement} AND (#{s}))"
200 statement_by_role[role] = "(#{statement} AND (#{s}))"
201 end
201 end
202 end
202 end
203 end
203 end
204 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
204 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
205 end
205 end
206 end
206 end
207 end
207 end
208
208
209 # Returns the Systemwide and project specific activities
209 # Returns the Systemwide and project specific activities
210 def activities(include_inactive=false)
210 def activities(include_inactive=false)
211 if include_inactive
211 if include_inactive
212 return all_activities
212 return all_activities
213 else
213 else
214 return active_activities
214 return active_activities
215 end
215 end
216 end
216 end
217
217
218 # Will create a new Project specific Activity or update an existing one
218 # Will create a new Project specific Activity or update an existing one
219 #
219 #
220 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
220 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
221 # does not successfully save.
221 # does not successfully save.
222 def update_or_create_time_entry_activity(id, activity_hash)
222 def update_or_create_time_entry_activity(id, activity_hash)
223 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
223 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
224 self.create_time_entry_activity_if_needed(activity_hash)
224 self.create_time_entry_activity_if_needed(activity_hash)
225 else
225 else
226 activity = project.time_entry_activities.find_by_id(id.to_i)
226 activity = project.time_entry_activities.find_by_id(id.to_i)
227 activity.update_attributes(activity_hash) if activity
227 activity.update_attributes(activity_hash) if activity
228 end
228 end
229 end
229 end
230
230
231 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
231 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
232 #
232 #
233 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
233 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
234 # does not successfully save.
234 # does not successfully save.
235 def create_time_entry_activity_if_needed(activity)
235 def create_time_entry_activity_if_needed(activity)
236 if activity['parent_id']
236 if activity['parent_id']
237
237
238 parent_activity = TimeEntryActivity.find(activity['parent_id'])
238 parent_activity = TimeEntryActivity.find(activity['parent_id'])
239 activity['name'] = parent_activity.name
239 activity['name'] = parent_activity.name
240 activity['position'] = parent_activity.position
240 activity['position'] = parent_activity.position
241
241
242 if Enumeration.overridding_change?(activity, parent_activity)
242 if Enumeration.overridding_change?(activity, parent_activity)
243 project_activity = self.time_entry_activities.create(activity)
243 project_activity = self.time_entry_activities.create(activity)
244
244
245 if project_activity.new_record?
245 if project_activity.new_record?
246 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
246 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
247 else
247 else
248 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
248 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
249 end
249 end
250 end
250 end
251 end
251 end
252 end
252 end
253
253
254 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
254 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
255 #
255 #
256 # Examples:
256 # Examples:
257 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
257 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
258 # project.project_condition(false) => "projects.id = 1"
258 # project.project_condition(false) => "projects.id = 1"
259 def project_condition(with_subprojects)
259 def project_condition(with_subprojects)
260 cond = "#{Project.table_name}.id = #{id}"
260 cond = "#{Project.table_name}.id = #{id}"
261 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
261 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
262 cond
262 cond
263 end
263 end
264
264
265 def self.find(*args)
265 def self.find(*args)
266 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
266 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
267 project = find_by_identifier(*args)
267 project = find_by_identifier(*args)
268 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
268 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
269 project
269 project
270 else
270 else
271 super
271 super
272 end
272 end
273 end
273 end
274
274
275 def self.find_by_param(*args)
275 def self.find_by_param(*args)
276 self.find(*args)
276 self.find(*args)
277 end
277 end
278
278
279 def reload(*args)
279 def reload(*args)
280 @shared_versions = nil
280 @shared_versions = nil
281 @rolled_up_versions = nil
281 @rolled_up_versions = nil
282 @rolled_up_trackers = nil
282 @rolled_up_trackers = nil
283 @all_issue_custom_fields = nil
283 @all_issue_custom_fields = nil
284 @all_time_entry_custom_fields = nil
284 @all_time_entry_custom_fields = nil
285 @to_param = nil
285 @to_param = nil
286 @allowed_parents = nil
286 @allowed_parents = nil
287 @allowed_permissions = nil
287 @allowed_permissions = nil
288 @actions_allowed = nil
288 @actions_allowed = nil
289 super
289 super
290 end
290 end
291
291
292 def to_param
292 def to_param
293 # id is used for projects with a numeric identifier (compatibility)
293 # id is used for projects with a numeric identifier (compatibility)
294 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
294 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
295 end
295 end
296
296
297 def active?
297 def active?
298 self.status == STATUS_ACTIVE
298 self.status == STATUS_ACTIVE
299 end
299 end
300
300
301 def archived?
301 def archived?
302 self.status == STATUS_ARCHIVED
302 self.status == STATUS_ARCHIVED
303 end
303 end
304
304
305 # Archives the project and its descendants
305 # Archives the project and its descendants
306 def archive
306 def archive
307 # Check that there is no issue of a non descendant project that is assigned
307 # Check that there is no issue of a non descendant project that is assigned
308 # to one of the project or descendant versions
308 # to one of the project or descendant versions
309 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
309 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
310 if v_ids.any? && Issue.find(:first, :include => :project,
310 if v_ids.any? && Issue.find(:first, :include => :project,
311 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
311 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
312 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
312 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
313 return false
313 return false
314 end
314 end
315 Project.transaction do
315 Project.transaction do
316 archive!
316 archive!
317 end
317 end
318 true
318 true
319 end
319 end
320
320
321 # Unarchives the project
321 # Unarchives the project
322 # All its ancestors must be active
322 # All its ancestors must be active
323 def unarchive
323 def unarchive
324 return false if ancestors.detect {|a| !a.active?}
324 return false if ancestors.detect {|a| !a.active?}
325 update_attribute :status, STATUS_ACTIVE
325 update_attribute :status, STATUS_ACTIVE
326 end
326 end
327
327
328 # Returns an array of projects the project can be moved to
328 # Returns an array of projects the project can be moved to
329 # by the current user
329 # by the current user
330 def allowed_parents
330 def allowed_parents
331 return @allowed_parents if @allowed_parents
331 return @allowed_parents if @allowed_parents
332 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
332 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
333 @allowed_parents = @allowed_parents - self_and_descendants
333 @allowed_parents = @allowed_parents - self_and_descendants
334 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
334 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
335 @allowed_parents << nil
335 @allowed_parents << nil
336 end
336 end
337 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
337 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
338 @allowed_parents << parent
338 @allowed_parents << parent
339 end
339 end
340 @allowed_parents
340 @allowed_parents
341 end
341 end
342
342
343 # Sets the parent of the project with authorization check
343 # Sets the parent of the project with authorization check
344 def set_allowed_parent!(p)
344 def set_allowed_parent!(p)
345 unless p.nil? || p.is_a?(Project)
345 unless p.nil? || p.is_a?(Project)
346 if p.to_s.blank?
346 if p.to_s.blank?
347 p = nil
347 p = nil
348 else
348 else
349 p = Project.find_by_id(p)
349 p = Project.find_by_id(p)
350 return false unless p
350 return false unless p
351 end
351 end
352 end
352 end
353 if p.nil?
353 if p.nil?
354 if !new_record? && allowed_parents.empty?
354 if !new_record? && allowed_parents.empty?
355 return false
355 return false
356 end
356 end
357 elsif !allowed_parents.include?(p)
357 elsif !allowed_parents.include?(p)
358 return false
358 return false
359 end
359 end
360 set_parent!(p)
360 set_parent!(p)
361 end
361 end
362
362
363 # Sets the parent of the project
363 # Sets the parent of the project
364 # Argument can be either a Project, a String, a Fixnum or nil
364 # Argument can be either a Project, a String, a Fixnum or nil
365 def set_parent!(p)
365 def set_parent!(p)
366 unless p.nil? || p.is_a?(Project)
366 unless p.nil? || p.is_a?(Project)
367 if p.to_s.blank?
367 if p.to_s.blank?
368 p = nil
368 p = nil
369 else
369 else
370 p = Project.find_by_id(p)
370 p = Project.find_by_id(p)
371 return false unless p
371 return false unless p
372 end
372 end
373 end
373 end
374 if p == parent && !p.nil?
374 if p == parent && !p.nil?
375 # Nothing to do
375 # Nothing to do
376 true
376 true
377 elsif p.nil? || (p.active? && move_possible?(p))
377 elsif p.nil? || (p.active? && move_possible?(p))
378 # Insert the project so that target's children or root projects stay alphabetically sorted
378 # Insert the project so that target's children or root projects stay alphabetically sorted
379 sibs = (p.nil? ? self.class.roots : p.children)
379 sibs = (p.nil? ? self.class.roots : p.children)
380 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
380 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
381 if to_be_inserted_before
381 if to_be_inserted_before
382 move_to_left_of(to_be_inserted_before)
382 move_to_left_of(to_be_inserted_before)
383 elsif p.nil?
383 elsif p.nil?
384 if sibs.empty?
384 if sibs.empty?
385 # move_to_root adds the project in first (ie. left) position
385 # move_to_root adds the project in first (ie. left) position
386 move_to_root
386 move_to_root
387 else
387 else
388 move_to_right_of(sibs.last) unless self == sibs.last
388 move_to_right_of(sibs.last) unless self == sibs.last
389 end
389 end
390 else
390 else
391 # move_to_child_of adds the project in last (ie.right) position
391 # move_to_child_of adds the project in last (ie.right) position
392 move_to_child_of(p)
392 move_to_child_of(p)
393 end
393 end
394 Issue.update_versions_from_hierarchy_change(self)
394 Issue.update_versions_from_hierarchy_change(self)
395 true
395 true
396 else
396 else
397 # Can not move to the given target
397 # Can not move to the given target
398 false
398 false
399 end
399 end
400 end
400 end
401
401
402 # Returns an array of the trackers used by the project and its active sub projects
402 # Returns an array of the trackers used by the project and its active sub projects
403 def rolled_up_trackers
403 def rolled_up_trackers
404 @rolled_up_trackers ||=
404 @rolled_up_trackers ||=
405 Tracker.find(:all, :joins => :projects,
405 Tracker.find(:all, :joins => :projects,
406 :select => "DISTINCT #{Tracker.table_name}.*",
406 :select => "DISTINCT #{Tracker.table_name}.*",
407 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
407 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
408 :order => "#{Tracker.table_name}.position")
408 :order => "#{Tracker.table_name}.position")
409 end
409 end
410
410
411 # Closes open and locked project versions that are completed
411 # Closes open and locked project versions that are completed
412 def close_completed_versions
412 def close_completed_versions
413 Version.transaction do
413 Version.transaction do
414 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
414 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
415 if version.completed?
415 if version.completed?
416 version.update_attribute(:status, 'closed')
416 version.update_attribute(:status, 'closed')
417 end
417 end
418 end
418 end
419 end
419 end
420 end
420 end
421
421
422 # Returns a scope of the Versions on subprojects
422 # Returns a scope of the Versions on subprojects
423 def rolled_up_versions
423 def rolled_up_versions
424 @rolled_up_versions ||=
424 @rolled_up_versions ||=
425 Version.scoped(:include => :project,
425 Version.scoped(:include => :project,
426 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
426 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
427 end
427 end
428
428
429 # Returns a scope of the Versions used by the project
429 # Returns a scope of the Versions used by the project
430 def shared_versions
430 def shared_versions
431 if new_record?
431 if new_record?
432 Version.scoped(:include => :project,
432 Version.scoped(:include => :project,
433 :conditions => "#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND #{Version.table_name}.sharing = 'system'")
433 :conditions => "#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND #{Version.table_name}.sharing = 'system'")
434 else
434 else
435 @shared_versions ||= begin
435 @shared_versions ||= begin
436 r = root? ? self : root
436 r = root? ? self : root
437 Version.scoped(:include => :project,
437 Version.scoped(:include => :project,
438 :conditions => "#{Project.table_name}.id = #{id}" +
438 :conditions => "#{Project.table_name}.id = #{id}" +
439 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
439 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
440 " #{Version.table_name}.sharing = 'system'" +
440 " #{Version.table_name}.sharing = 'system'" +
441 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
441 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
442 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
442 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
443 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
443 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
444 "))")
444 "))")
445 end
445 end
446 end
446 end
447 end
447 end
448
448
449 # Returns a hash of project users grouped by role
449 # Returns a hash of project users grouped by role
450 def users_by_role
450 def users_by_role
451 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
451 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
452 m.roles.each do |r|
452 m.roles.each do |r|
453 h[r] ||= []
453 h[r] ||= []
454 h[r] << m.user
454 h[r] << m.user
455 end
455 end
456 h
456 h
457 end
457 end
458 end
458 end
459
459
460 # Deletes all project's members
460 # Deletes all project's members
461 def delete_all_members
461 def delete_all_members
462 me, mr = Member.table_name, MemberRole.table_name
462 me, mr = Member.table_name, MemberRole.table_name
463 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
463 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
464 Member.delete_all(['project_id = ?', id])
464 Member.delete_all(['project_id = ?', id])
465 end
465 end
466
466
467 # Users/groups issues can be assigned to
467 # Users/groups issues can be assigned to
468 def assignable_users
468 def assignable_users
469 assignable = Setting.issue_group_assignment? ? member_principals : members
469 assignable = Setting.issue_group_assignment? ? member_principals : members
470 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
470 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
471 end
471 end
472
472
473 # Returns the mail adresses of users that should be always notified on project events
473 # Returns the mail adresses of users that should be always notified on project events
474 def recipients
474 def recipients
475 notified_users.collect {|user| user.mail}
475 notified_users.collect {|user| user.mail}
476 end
476 end
477
477
478 # Returns the users that should be notified on project events
478 # Returns the users that should be notified on project events
479 def notified_users
479 def notified_users
480 # TODO: User part should be extracted to User#notify_about?
480 # TODO: User part should be extracted to User#notify_about?
481 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
481 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
482 end
482 end
483
483
484 # Returns an array of all custom fields enabled for project issues
484 # Returns an array of all custom fields enabled for project issues
485 # (explictly associated custom fields and custom fields enabled for all projects)
485 # (explictly associated custom fields and custom fields enabled for all projects)
486 def all_issue_custom_fields
486 def all_issue_custom_fields
487 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
487 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
488 end
488 end
489
489
490 # Returns an array of all custom fields enabled for project time entries
490 # Returns an array of all custom fields enabled for project time entries
491 # (explictly associated custom fields and custom fields enabled for all projects)
491 # (explictly associated custom fields and custom fields enabled for all projects)
492 def all_time_entry_custom_fields
492 def all_time_entry_custom_fields
493 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
493 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
494 end
494 end
495
495
496 def project
496 def project
497 self
497 self
498 end
498 end
499
499
500 def <=>(project)
500 def <=>(project)
501 name.downcase <=> project.name.downcase
501 name.downcase <=> project.name.downcase
502 end
502 end
503
503
504 def to_s
504 def to_s
505 name
505 name
506 end
506 end
507
507
508 # Returns a short description of the projects (first lines)
508 # Returns a short description of the projects (first lines)
509 def short_description(length = 255)
509 def short_description(length = 255)
510 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
510 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
511 end
511 end
512
512
513 def css_classes
513 def css_classes
514 s = 'project'
514 s = 'project'
515 s << ' root' if root?
515 s << ' root' if root?
516 s << ' child' if child?
516 s << ' child' if child?
517 s << (leaf? ? ' leaf' : ' parent')
517 s << (leaf? ? ' leaf' : ' parent')
518 s
518 s
519 end
519 end
520
520
521 # The earliest start date of a project, based on it's issues and versions
521 # The earliest start date of a project, based on it's issues and versions
522 def start_date
522 def start_date
523 [
523 [
524 issues.minimum('start_date'),
524 issues.minimum('start_date'),
525 shared_versions.collect(&:effective_date),
525 shared_versions.collect(&:effective_date),
526 shared_versions.collect(&:start_date)
526 shared_versions.collect(&:start_date)
527 ].flatten.compact.min
527 ].flatten.compact.min
528 end
528 end
529
529
530 # The latest due date of an issue or version
530 # The latest due date of an issue or version
531 def due_date
531 def due_date
532 [
532 [
533 issues.maximum('due_date'),
533 issues.maximum('due_date'),
534 shared_versions.collect(&:effective_date),
534 shared_versions.collect(&:effective_date),
535 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
535 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
536 ].flatten.compact.max
536 ].flatten.compact.max
537 end
537 end
538
538
539 def overdue?
539 def overdue?
540 active? && !due_date.nil? && (due_date < Date.today)
540 active? && !due_date.nil? && (due_date < Date.today)
541 end
541 end
542
542
543 # Returns the percent completed for this project, based on the
543 # Returns the percent completed for this project, based on the
544 # progress on it's versions.
544 # progress on it's versions.
545 def completed_percent(options={:include_subprojects => false})
545 def completed_percent(options={:include_subprojects => false})
546 if options.delete(:include_subprojects)
546 if options.delete(:include_subprojects)
547 total = self_and_descendants.collect(&:completed_percent).sum
547 total = self_and_descendants.collect(&:completed_percent).sum
548
548
549 total / self_and_descendants.count
549 total / self_and_descendants.count
550 else
550 else
551 if versions.count > 0
551 if versions.count > 0
552 total = versions.collect(&:completed_pourcent).sum
552 total = versions.collect(&:completed_pourcent).sum
553
553
554 total / versions.count
554 total / versions.count
555 else
555 else
556 100
556 100
557 end
557 end
558 end
558 end
559 end
559 end
560
560
561 # Return true if this project is allowed to do the specified action.
561 # Return true if this project is allowed to do the specified action.
562 # action can be:
562 # action can be:
563 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
563 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
564 # * a permission Symbol (eg. :edit_project)
564 # * a permission Symbol (eg. :edit_project)
565 def allows_to?(action)
565 def allows_to?(action)
566 if action.is_a? Hash
566 if action.is_a? Hash
567 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
567 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
568 else
568 else
569 allowed_permissions.include? action
569 allowed_permissions.include? action
570 end
570 end
571 end
571 end
572
572
573 def module_enabled?(module_name)
573 def module_enabled?(module_name)
574 module_name = module_name.to_s
574 module_name = module_name.to_s
575 enabled_modules.detect {|m| m.name == module_name}
575 enabled_modules.detect {|m| m.name == module_name}
576 end
576 end
577
577
578 def enabled_module_names=(module_names)
578 def enabled_module_names=(module_names)
579 if module_names && module_names.is_a?(Array)
579 if module_names && module_names.is_a?(Array)
580 module_names = module_names.collect(&:to_s).reject(&:blank?)
580 module_names = module_names.collect(&:to_s).reject(&:blank?)
581 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
581 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
582 else
582 else
583 enabled_modules.clear
583 enabled_modules.clear
584 end
584 end
585 end
585 end
586
586
587 # Returns an array of the enabled modules names
587 # Returns an array of the enabled modules names
588 def enabled_module_names
588 def enabled_module_names
589 enabled_modules.collect(&:name)
589 enabled_modules.collect(&:name)
590 end
590 end
591
591
592 # Enable a specific module
592 # Enable a specific module
593 #
593 #
594 # Examples:
594 # Examples:
595 # project.enable_module!(:issue_tracking)
595 # project.enable_module!(:issue_tracking)
596 # project.enable_module!("issue_tracking")
596 # project.enable_module!("issue_tracking")
597 def enable_module!(name)
597 def enable_module!(name)
598 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
598 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
599 end
599 end
600
600
601 # Disable a module if it exists
601 # Disable a module if it exists
602 #
602 #
603 # Examples:
603 # Examples:
604 # project.disable_module!(:issue_tracking)
604 # project.disable_module!(:issue_tracking)
605 # project.disable_module!("issue_tracking")
605 # project.disable_module!("issue_tracking")
606 # project.disable_module!(project.enabled_modules.first)
606 # project.disable_module!(project.enabled_modules.first)
607 def disable_module!(target)
607 def disable_module!(target)
608 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
608 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
609 target.destroy unless target.blank?
609 target.destroy unless target.blank?
610 end
610 end
611
611
612 safe_attributes 'name',
612 safe_attributes 'name',
613 'description',
613 'description',
614 'homepage',
614 'homepage',
615 'is_public',
615 'is_public',
616 'identifier',
616 'identifier',
617 'custom_field_values',
617 'custom_field_values',
618 'custom_fields',
618 'custom_fields',
619 'tracker_ids',
619 'tracker_ids',
620 'issue_custom_field_ids'
620 'issue_custom_field_ids'
621
621
622 safe_attributes 'enabled_module_names',
622 safe_attributes 'enabled_module_names',
623 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
623 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
624
624
625 # Returns an array of projects that are in this project's hierarchy
625 # Returns an array of projects that are in this project's hierarchy
626 #
626 #
627 # Example: parents, children, siblings
627 # Example: parents, children, siblings
628 def hierarchy
628 def hierarchy
629 parents = project.self_and_ancestors || []
629 parents = project.self_and_ancestors || []
630 descendants = project.descendants || []
630 descendants = project.descendants || []
631 project_hierarchy = parents | descendants # Set union
631 project_hierarchy = parents | descendants # Set union
632 end
632 end
633
633
634 # Returns an auto-generated project identifier based on the last identifier used
634 # Returns an auto-generated project identifier based on the last identifier used
635 def self.next_identifier
635 def self.next_identifier
636 p = Project.find(:first, :order => 'created_on DESC')
636 p = Project.find(:first, :order => 'created_on DESC')
637 p.nil? ? nil : p.identifier.to_s.succ
637 p.nil? ? nil : p.identifier.to_s.succ
638 end
638 end
639
639
640 # Copies and saves the Project instance based on the +project+.
640 # Copies and saves the Project instance based on the +project+.
641 # Duplicates the source project's:
641 # Duplicates the source project's:
642 # * Wiki
642 # * Wiki
643 # * Versions
643 # * Versions
644 # * Categories
644 # * Categories
645 # * Issues
645 # * Issues
646 # * Members
646 # * Members
647 # * Queries
647 # * Queries
648 #
648 #
649 # Accepts an +options+ argument to specify what to copy
649 # Accepts an +options+ argument to specify what to copy
650 #
650 #
651 # Examples:
651 # Examples:
652 # project.copy(1) # => copies everything
652 # project.copy(1) # => copies everything
653 # project.copy(1, :only => 'members') # => copies members only
653 # project.copy(1, :only => 'members') # => copies members only
654 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
654 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
655 def copy(project, options={})
655 def copy(project, options={})
656 project = project.is_a?(Project) ? project : Project.find(project)
656 project = project.is_a?(Project) ? project : Project.find(project)
657
657
658 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
658 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
659 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
659 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
660
660
661 Project.transaction do
661 Project.transaction do
662 if save
662 if save
663 reload
663 reload
664 to_be_copied.each do |name|
664 to_be_copied.each do |name|
665 send "copy_#{name}", project
665 send "copy_#{name}", project
666 end
666 end
667 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
667 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
668 save
668 save
669 end
669 end
670 end
670 end
671 end
671 end
672
672
673
673
674 # Copies +project+ and returns the new instance. This will not save
674 # Copies +project+ and returns the new instance. This will not save
675 # the copy
675 # the copy
676 def self.copy_from(project)
676 def self.copy_from(project)
677 begin
677 begin
678 project = project.is_a?(Project) ? project : Project.find(project)
678 project = project.is_a?(Project) ? project : Project.find(project)
679 if project
679 if project
680 # clear unique attributes
680 # clear unique attributes
681 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
681 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
682 copy = Project.new(attributes)
682 copy = Project.new(attributes)
683 copy.enabled_modules = project.enabled_modules
683 copy.enabled_modules = project.enabled_modules
684 copy.trackers = project.trackers
684 copy.trackers = project.trackers
685 copy.custom_values = project.custom_values.collect {|v| v.clone}
685 copy.custom_values = project.custom_values.collect {|v| v.clone}
686 copy.issue_custom_fields = project.issue_custom_fields
686 copy.issue_custom_fields = project.issue_custom_fields
687 return copy
687 return copy
688 else
688 else
689 return nil
689 return nil
690 end
690 end
691 rescue ActiveRecord::RecordNotFound
691 rescue ActiveRecord::RecordNotFound
692 return nil
692 return nil
693 end
693 end
694 end
694 end
695
695
696 # Yields the given block for each project with its level in the tree
696 # Yields the given block for each project with its level in the tree
697 def self.project_tree(projects, &block)
697 def self.project_tree(projects, &block)
698 ancestors = []
698 ancestors = []
699 projects.sort_by(&:lft).each do |project|
699 projects.sort_by(&:lft).each do |project|
700 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
700 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
701 ancestors.pop
701 ancestors.pop
702 end
702 end
703 yield project, ancestors.size
703 yield project, ancestors.size
704 ancestors << project
704 ancestors << project
705 end
705 end
706 end
706 end
707
707
708 private
708 private
709
709
710 # Copies wiki from +project+
710 # Copies wiki from +project+
711 def copy_wiki(project)
711 def copy_wiki(project)
712 # Check that the source project has a wiki first
712 # Check that the source project has a wiki first
713 unless project.wiki.nil?
713 unless project.wiki.nil?
714 self.wiki ||= Wiki.new
714 self.wiki ||= Wiki.new
715 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
715 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
716 wiki_pages_map = {}
716 wiki_pages_map = {}
717 project.wiki.pages.each do |page|
717 project.wiki.pages.each do |page|
718 # Skip pages without content
718 # Skip pages without content
719 next if page.content.nil?
719 next if page.content.nil?
720 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
720 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
721 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
721 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
722 new_wiki_page.content = new_wiki_content
722 new_wiki_page.content = new_wiki_content
723 wiki.pages << new_wiki_page
723 wiki.pages << new_wiki_page
724 wiki_pages_map[page.id] = new_wiki_page
724 wiki_pages_map[page.id] = new_wiki_page
725 end
725 end
726 wiki.save
726 wiki.save
727 # Reproduce page hierarchy
727 # Reproduce page hierarchy
728 project.wiki.pages.each do |page|
728 project.wiki.pages.each do |page|
729 if page.parent_id && wiki_pages_map[page.id]
729 if page.parent_id && wiki_pages_map[page.id]
730 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
730 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
731 wiki_pages_map[page.id].save
731 wiki_pages_map[page.id].save
732 end
732 end
733 end
733 end
734 end
734 end
735 end
735 end
736
736
737 # Copies versions from +project+
737 # Copies versions from +project+
738 def copy_versions(project)
738 def copy_versions(project)
739 project.versions.each do |version|
739 project.versions.each do |version|
740 new_version = Version.new
740 new_version = Version.new
741 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
741 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
742 self.versions << new_version
742 self.versions << new_version
743 end
743 end
744 end
744 end
745
745
746 # Copies issue categories from +project+
746 # Copies issue categories from +project+
747 def copy_issue_categories(project)
747 def copy_issue_categories(project)
748 project.issue_categories.each do |issue_category|
748 project.issue_categories.each do |issue_category|
749 new_issue_category = IssueCategory.new
749 new_issue_category = IssueCategory.new
750 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
750 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
751 self.issue_categories << new_issue_category
751 self.issue_categories << new_issue_category
752 end
752 end
753 end
753 end
754
754
755 # Copies issues from +project+
755 # Copies issues from +project+
756 # Note: issues assigned to a closed version won't be copied due to validation rules
756 # Note: issues assigned to a closed version won't be copied due to validation rules
757 def copy_issues(project)
757 def copy_issues(project)
758 # Stores the source issue id as a key and the copied issues as the
758 # Stores the source issue id as a key and the copied issues as the
759 # value. Used to map the two togeather for issue relations.
759 # value. Used to map the two togeather for issue relations.
760 issues_map = {}
760 issues_map = {}
761
761
762 # Get issues sorted by root_id, lft so that parent issues
762 # Get issues sorted by root_id, lft so that parent issues
763 # get copied before their children
763 # get copied before their children
764 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
764 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
765 new_issue = Issue.new
765 new_issue = Issue.new
766 new_issue.copy_from(issue)
766 new_issue.copy_from(issue)
767 new_issue.project = self
767 new_issue.project = self
768 # Reassign fixed_versions by name, since names are unique per
768 # Reassign fixed_versions by name, since names are unique per
769 # project and the versions for self are not yet saved
769 # project and the versions for self are not yet saved
770 if issue.fixed_version
770 if issue.fixed_version
771 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
771 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
772 end
772 end
773 # Reassign the category by name, since names are unique per
773 # Reassign the category by name, since names are unique per
774 # project and the categories for self are not yet saved
774 # project and the categories for self are not yet saved
775 if issue.category
775 if issue.category
776 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
776 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
777 end
777 end
778 # Parent issue
778 # Parent issue
779 if issue.parent_id
779 if issue.parent_id
780 if copied_parent = issues_map[issue.parent_id]
780 if copied_parent = issues_map[issue.parent_id]
781 new_issue.parent_issue_id = copied_parent.id
781 new_issue.parent_issue_id = copied_parent.id
782 end
782 end
783 end
783 end
784
784
785 self.issues << new_issue
785 self.issues << new_issue
786 if new_issue.new_record?
786 if new_issue.new_record?
787 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
787 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
788 else
788 else
789 issues_map[issue.id] = new_issue unless new_issue.new_record?
789 issues_map[issue.id] = new_issue unless new_issue.new_record?
790 end
790 end
791 end
791 end
792
792
793 # Relations after in case issues related each other
793 # Relations after in case issues related each other
794 project.issues.each do |issue|
794 project.issues.each do |issue|
795 new_issue = issues_map[issue.id]
795 new_issue = issues_map[issue.id]
796 unless new_issue
796 unless new_issue
797 # Issue was not copied
797 # Issue was not copied
798 next
798 next
799 end
799 end
800
800
801 # Relations
801 # Relations
802 issue.relations_from.each do |source_relation|
802 issue.relations_from.each do |source_relation|
803 new_issue_relation = IssueRelation.new
803 new_issue_relation = IssueRelation.new
804 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
804 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
805 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
805 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
806 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
806 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
807 new_issue_relation.issue_to = source_relation.issue_to
807 new_issue_relation.issue_to = source_relation.issue_to
808 end
808 end
809 new_issue.relations_from << new_issue_relation
809 new_issue.relations_from << new_issue_relation
810 end
810 end
811
811
812 issue.relations_to.each do |source_relation|
812 issue.relations_to.each do |source_relation|
813 new_issue_relation = IssueRelation.new
813 new_issue_relation = IssueRelation.new
814 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
814 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
815 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
815 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
816 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
816 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
817 new_issue_relation.issue_from = source_relation.issue_from
817 new_issue_relation.issue_from = source_relation.issue_from
818 end
818 end
819 new_issue.relations_to << new_issue_relation
819 new_issue.relations_to << new_issue_relation
820 end
820 end
821 end
821 end
822 end
822 end
823
823
824 # Copies members from +project+
824 # Copies members from +project+
825 def copy_members(project)
825 def copy_members(project)
826 # Copy users first, then groups to handle members with inherited and given roles
826 # Copy users first, then groups to handle members with inherited and given roles
827 members_to_copy = []
827 members_to_copy = []
828 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
828 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
829 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
829 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
830
830
831 members_to_copy.each do |member|
831 members_to_copy.each do |member|
832 new_member = Member.new
832 new_member = Member.new
833 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
833 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
834 # only copy non inherited roles
834 # only copy non inherited roles
835 # inherited roles will be added when copying the group membership
835 # inherited roles will be added when copying the group membership
836 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
836 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
837 next if role_ids.empty?
837 next if role_ids.empty?
838 new_member.role_ids = role_ids
838 new_member.role_ids = role_ids
839 new_member.project = self
839 new_member.project = self
840 self.members << new_member
840 self.members << new_member
841 end
841 end
842 end
842 end
843
843
844 # Copies queries from +project+
844 # Copies queries from +project+
845 def copy_queries(project)
845 def copy_queries(project)
846 project.queries.each do |query|
846 project.queries.each do |query|
847 new_query = ::Query.new
847 new_query = ::Query.new
848 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
848 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
849 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
849 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
850 new_query.project = self
850 new_query.project = self
851 new_query.user_id = query.user_id
851 new_query.user_id = query.user_id
852 self.queries << new_query
852 self.queries << new_query
853 end
853 end
854 end
854 end
855
855
856 # Copies boards from +project+
856 # Copies boards from +project+
857 def copy_boards(project)
857 def copy_boards(project)
858 project.boards.each do |board|
858 project.boards.each do |board|
859 new_board = Board.new
859 new_board = Board.new
860 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
860 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
861 new_board.project = self
861 new_board.project = self
862 self.boards << new_board
862 self.boards << new_board
863 end
863 end
864 end
864 end
865
865
866 def allowed_permissions
866 def allowed_permissions
867 @allowed_permissions ||= begin
867 @allowed_permissions ||= begin
868 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
868 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
869 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
869 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
870 end
870 end
871 end
871 end
872
872
873 def allowed_actions
873 def allowed_actions
874 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
874 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
875 end
875 end
876
876
877 # Returns all the active Systemwide and project specific activities
877 # Returns all the active Systemwide and project specific activities
878 def active_activities
878 def active_activities
879 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
879 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
880
880
881 if overridden_activity_ids.empty?
881 if overridden_activity_ids.empty?
882 return TimeEntryActivity.shared.active
882 return TimeEntryActivity.shared.active
883 else
883 else
884 return system_activities_and_project_overrides
884 return system_activities_and_project_overrides
885 end
885 end
886 end
886 end
887
887
888 # Returns all the Systemwide and project specific activities
888 # Returns all the Systemwide and project specific activities
889 # (inactive and active)
889 # (inactive and active)
890 def all_activities
890 def all_activities
891 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
891 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
892
892
893 if overridden_activity_ids.empty?
893 if overridden_activity_ids.empty?
894 return TimeEntryActivity.shared
894 return TimeEntryActivity.shared
895 else
895 else
896 return system_activities_and_project_overrides(true)
896 return system_activities_and_project_overrides(true)
897 end
897 end
898 end
898 end
899
899
900 # Returns the systemwide active activities merged with the project specific overrides
900 # Returns the systemwide active activities merged with the project specific overrides
901 def system_activities_and_project_overrides(include_inactive=false)
901 def system_activities_and_project_overrides(include_inactive=false)
902 if include_inactive
902 if include_inactive
903 return TimeEntryActivity.shared.
903 return TimeEntryActivity.shared.
904 find(:all,
904 find(:all,
905 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
905 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
906 self.time_entry_activities
906 self.time_entry_activities
907 else
907 else
908 return TimeEntryActivity.shared.active.
908 return TimeEntryActivity.shared.active.
909 find(:all,
909 find(:all,
910 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
910 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
911 self.time_entry_activities.active
911 self.time_entry_activities.active
912 end
912 end
913 end
913 end
914
914
915 # Archives subprojects recursively
915 # Archives subprojects recursively
916 def archive!
916 def archive!
917 children.each do |subproject|
917 children.each do |subproject|
918 subproject.send :archive!
918 subproject.send :archive!
919 end
919 end
920 update_attribute :status, STATUS_ARCHIVED
920 update_attribute :status, STATUS_ARCHIVED
921 end
921 end
922 end
922 end
@@ -1,865 +1,865
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class QueryColumn
18 class QueryColumn
19 attr_accessor :name, :sortable, :groupable, :default_order
19 attr_accessor :name, :sortable, :groupable, :default_order
20 include Redmine::I18n
20 include Redmine::I18n
21
21
22 def initialize(name, options={})
22 def initialize(name, options={})
23 self.name = name
23 self.name = name
24 self.sortable = options[:sortable]
24 self.sortable = options[:sortable]
25 self.groupable = options[:groupable] || false
25 self.groupable = options[:groupable] || false
26 if groupable == true
26 if groupable == true
27 self.groupable = name.to_s
27 self.groupable = name.to_s
28 end
28 end
29 self.default_order = options[:default_order]
29 self.default_order = options[:default_order]
30 @caption_key = options[:caption] || "field_#{name}"
30 @caption_key = options[:caption] || "field_#{name}"
31 end
31 end
32
32
33 def caption
33 def caption
34 l(@caption_key)
34 l(@caption_key)
35 end
35 end
36
36
37 # Returns true if the column is sortable, otherwise false
37 # Returns true if the column is sortable, otherwise false
38 def sortable?
38 def sortable?
39 !@sortable.nil?
39 !@sortable.nil?
40 end
40 end
41
41
42 def sortable
42 def sortable
43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 end
44 end
45
45
46 def value(issue)
46 def value(issue)
47 issue.send name
47 issue.send name
48 end
48 end
49
49
50 def css_classes
50 def css_classes
51 name
51 name
52 end
52 end
53 end
53 end
54
54
55 class QueryCustomFieldColumn < QueryColumn
55 class QueryCustomFieldColumn < QueryColumn
56
56
57 def initialize(custom_field)
57 def initialize(custom_field)
58 self.name = "cf_#{custom_field.id}".to_sym
58 self.name = "cf_#{custom_field.id}".to_sym
59 self.sortable = custom_field.order_statement || false
59 self.sortable = custom_field.order_statement || false
60 if %w(list date bool int).include?(custom_field.field_format) && !custom_field.multiple?
60 if %w(list date bool int).include?(custom_field.field_format) && !custom_field.multiple?
61 self.groupable = custom_field.order_statement
61 self.groupable = custom_field.order_statement
62 end
62 end
63 self.groupable ||= false
63 self.groupable ||= false
64 @cf = custom_field
64 @cf = custom_field
65 end
65 end
66
66
67 def caption
67 def caption
68 @cf.name
68 @cf.name
69 end
69 end
70
70
71 def custom_field
71 def custom_field
72 @cf
72 @cf
73 end
73 end
74
74
75 def value(issue)
75 def value(issue)
76 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
76 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
77 cv.size > 1 ? cv : cv.first
77 cv.size > 1 ? cv : cv.first
78 end
78 end
79
79
80 def css_classes
80 def css_classes
81 @css_classes ||= "#{name} #{@cf.field_format}"
81 @css_classes ||= "#{name} #{@cf.field_format}"
82 end
82 end
83 end
83 end
84
84
85 class Query < ActiveRecord::Base
85 class Query < ActiveRecord::Base
86 class StatementInvalid < ::ActiveRecord::StatementInvalid
86 class StatementInvalid < ::ActiveRecord::StatementInvalid
87 end
87 end
88
88
89 belongs_to :project
89 belongs_to :project
90 belongs_to :user
90 belongs_to :user
91 serialize :filters
91 serialize :filters
92 serialize :column_names
92 serialize :column_names
93 serialize :sort_criteria, Array
93 serialize :sort_criteria, Array
94
94
95 attr_protected :project_id, :user_id
95 attr_protected :project_id, :user_id
96
96
97 validates_presence_of :name
97 validates_presence_of :name
98 validates_length_of :name, :maximum => 255
98 validates_length_of :name, :maximum => 255
99 validate :validate_query_filters
99 validate :validate_query_filters
100
100
101 @@operators = { "=" => :label_equals,
101 @@operators = { "=" => :label_equals,
102 "!" => :label_not_equals,
102 "!" => :label_not_equals,
103 "o" => :label_open_issues,
103 "o" => :label_open_issues,
104 "c" => :label_closed_issues,
104 "c" => :label_closed_issues,
105 "!*" => :label_none,
105 "!*" => :label_none,
106 "*" => :label_all,
106 "*" => :label_all,
107 ">=" => :label_greater_or_equal,
107 ">=" => :label_greater_or_equal,
108 "<=" => :label_less_or_equal,
108 "<=" => :label_less_or_equal,
109 "><" => :label_between,
109 "><" => :label_between,
110 "<t+" => :label_in_less_than,
110 "<t+" => :label_in_less_than,
111 ">t+" => :label_in_more_than,
111 ">t+" => :label_in_more_than,
112 "t+" => :label_in,
112 "t+" => :label_in,
113 "t" => :label_today,
113 "t" => :label_today,
114 "w" => :label_this_week,
114 "w" => :label_this_week,
115 ">t-" => :label_less_than_ago,
115 ">t-" => :label_less_than_ago,
116 "<t-" => :label_more_than_ago,
116 "<t-" => :label_more_than_ago,
117 "t-" => :label_ago,
117 "t-" => :label_ago,
118 "~" => :label_contains,
118 "~" => :label_contains,
119 "!~" => :label_not_contains }
119 "!~" => :label_not_contains }
120
120
121 cattr_reader :operators
121 cattr_reader :operators
122
122
123 @@operators_by_filter_type = { :list => [ "=", "!" ],
123 @@operators_by_filter_type = { :list => [ "=", "!" ],
124 :list_status => [ "o", "=", "!", "c", "*" ],
124 :list_status => [ "o", "=", "!", "c", "*" ],
125 :list_optional => [ "=", "!", "!*", "*" ],
125 :list_optional => [ "=", "!", "!*", "*" ],
126 :list_subprojects => [ "*", "!*", "=" ],
126 :list_subprojects => [ "*", "!*", "=" ],
127 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-", "!*", "*" ],
127 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-", "!*", "*" ],
128 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "t-", "t", "w", "!*", "*" ],
128 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "t-", "t", "w", "!*", "*" ],
129 :string => [ "=", "~", "!", "!~" ],
129 :string => [ "=", "~", "!", "!~" ],
130 :text => [ "~", "!~" ],
130 :text => [ "~", "!~" ],
131 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
131 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
132 :float => [ "=", ">=", "<=", "><", "!*", "*" ] }
132 :float => [ "=", ">=", "<=", "><", "!*", "*" ] }
133
133
134 cattr_reader :operators_by_filter_type
134 cattr_reader :operators_by_filter_type
135
135
136 @@available_columns = [
136 @@available_columns = [
137 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
137 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
138 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
138 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
139 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
139 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
140 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
140 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
141 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
141 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
142 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
142 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
143 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
143 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
144 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
144 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
145 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
145 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
146 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
146 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
147 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
147 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
148 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
148 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
149 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
149 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
150 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
150 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
151 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
151 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
152 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
152 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
153 ]
153 ]
154 cattr_reader :available_columns
154 cattr_reader :available_columns
155
155
156 named_scope :visible, lambda {|*args|
156 scope :visible, lambda {|*args|
157 user = args.shift || User.current
157 user = args.shift || User.current
158 base = Project.allowed_to_condition(user, :view_issues, *args)
158 base = Project.allowed_to_condition(user, :view_issues, *args)
159 user_id = user.logged? ? user.id : 0
159 user_id = user.logged? ? user.id : 0
160 {
160 {
161 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
161 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
162 :include => :project
162 :include => :project
163 }
163 }
164 }
164 }
165
165
166 def initialize(attributes=nil, *args)
166 def initialize(attributes=nil, *args)
167 super attributes
167 super attributes
168 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
168 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
169 @is_for_all = project.nil?
169 @is_for_all = project.nil?
170 end
170 end
171
171
172 def validate_query_filters
172 def validate_query_filters
173 filters.each_key do |field|
173 filters.each_key do |field|
174 if values_for(field)
174 if values_for(field)
175 case type_for(field)
175 case type_for(field)
176 when :integer
176 when :integer
177 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
177 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
178 when :float
178 when :float
179 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+(\.\d*)?$/) }
179 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+(\.\d*)?$/) }
180 when :date, :date_past
180 when :date, :date_past
181 case operator_for(field)
181 case operator_for(field)
182 when "=", ">=", "<=", "><"
182 when "=", ">=", "<=", "><"
183 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
183 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
184 when ">t-", "<t-", "t-"
184 when ">t-", "<t-", "t-"
185 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
185 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
186 end
186 end
187 end
187 end
188 end
188 end
189
189
190 add_filter_error(field, :blank) unless
190 add_filter_error(field, :blank) unless
191 # filter requires one or more values
191 # filter requires one or more values
192 (values_for(field) and !values_for(field).first.blank?) or
192 (values_for(field) and !values_for(field).first.blank?) or
193 # filter doesn't require any value
193 # filter doesn't require any value
194 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
194 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
195 end if filters
195 end if filters
196 end
196 end
197
197
198 def add_filter_error(field, message)
198 def add_filter_error(field, message)
199 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
199 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
200 errors.add(:base, m)
200 errors.add(:base, m)
201 end
201 end
202
202
203 # Returns true if the query is visible to +user+ or the current user.
203 # Returns true if the query is visible to +user+ or the current user.
204 def visible?(user=User.current)
204 def visible?(user=User.current)
205 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
205 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
206 end
206 end
207
207
208 def editable_by?(user)
208 def editable_by?(user)
209 return false unless user
209 return false unless user
210 # Admin can edit them all and regular users can edit their private queries
210 # Admin can edit them all and regular users can edit their private queries
211 return true if user.admin? || (!is_public && self.user_id == user.id)
211 return true if user.admin? || (!is_public && self.user_id == user.id)
212 # Members can not edit public queries that are for all project (only admin is allowed to)
212 # Members can not edit public queries that are for all project (only admin is allowed to)
213 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
213 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
214 end
214 end
215
215
216 def available_filters
216 def available_filters
217 return @available_filters if @available_filters
217 return @available_filters if @available_filters
218
218
219 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
219 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
220
220
221 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
221 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
222 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
222 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
223 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
223 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
224 "subject" => { :type => :text, :order => 8 },
224 "subject" => { :type => :text, :order => 8 },
225 "created_on" => { :type => :date_past, :order => 9 },
225 "created_on" => { :type => :date_past, :order => 9 },
226 "updated_on" => { :type => :date_past, :order => 10 },
226 "updated_on" => { :type => :date_past, :order => 10 },
227 "start_date" => { :type => :date, :order => 11 },
227 "start_date" => { :type => :date, :order => 11 },
228 "due_date" => { :type => :date, :order => 12 },
228 "due_date" => { :type => :date, :order => 12 },
229 "estimated_hours" => { :type => :float, :order => 13 },
229 "estimated_hours" => { :type => :float, :order => 13 },
230 "done_ratio" => { :type => :integer, :order => 14 }}
230 "done_ratio" => { :type => :integer, :order => 14 }}
231
231
232 principals = []
232 principals = []
233 if project
233 if project
234 principals += project.principals.sort
234 principals += project.principals.sort
235 unless project.leaf?
235 unless project.leaf?
236 subprojects = project.descendants.visible.all
236 subprojects = project.descendants.visible.all
237 if subprojects.any?
237 if subprojects.any?
238 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => subprojects.collect{|s| [s.name, s.id.to_s] } }
238 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => subprojects.collect{|s| [s.name, s.id.to_s] } }
239 principals += Principal.member_of(subprojects)
239 principals += Principal.member_of(subprojects)
240 end
240 end
241 end
241 end
242 else
242 else
243 all_projects = Project.visible.all
243 all_projects = Project.visible.all
244 if all_projects.any?
244 if all_projects.any?
245 # members of visible projects
245 # members of visible projects
246 principals += Principal.member_of(all_projects)
246 principals += Principal.member_of(all_projects)
247
247
248 # project filter
248 # project filter
249 project_values = []
249 project_values = []
250 if User.current.logged? && User.current.memberships.any?
250 if User.current.logged? && User.current.memberships.any?
251 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
251 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
252 end
252 end
253 Project.project_tree(all_projects) do |p, level|
253 Project.project_tree(all_projects) do |p, level|
254 prefix = (level > 0 ? ('--' * level + ' ') : '')
254 prefix = (level > 0 ? ('--' * level + ' ') : '')
255 project_values << ["#{prefix}#{p.name}", p.id.to_s]
255 project_values << ["#{prefix}#{p.name}", p.id.to_s]
256 end
256 end
257 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
257 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
258 end
258 end
259 end
259 end
260 principals.uniq!
260 principals.uniq!
261 principals.sort!
261 principals.sort!
262 users = principals.select {|p| p.is_a?(User)}
262 users = principals.select {|p| p.is_a?(User)}
263
263
264 assigned_to_values = []
264 assigned_to_values = []
265 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
265 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
266 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
266 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
267 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => assigned_to_values } unless assigned_to_values.empty?
267 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => assigned_to_values } unless assigned_to_values.empty?
268
268
269 author_values = []
269 author_values = []
270 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
270 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
271 author_values += users.collect{|s| [s.name, s.id.to_s] }
271 author_values += users.collect{|s| [s.name, s.id.to_s] }
272 @available_filters["author_id"] = { :type => :list, :order => 5, :values => author_values } unless author_values.empty?
272 @available_filters["author_id"] = { :type => :list, :order => 5, :values => author_values } unless author_values.empty?
273
273
274 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
274 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
275 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
275 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
276
276
277 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
277 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
278 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
278 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
279
279
280 if User.current.logged?
280 if User.current.logged?
281 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
281 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
282 end
282 end
283
283
284 if project
284 if project
285 # project specific filters
285 # project specific filters
286 categories = project.issue_categories.all
286 categories = project.issue_categories.all
287 unless categories.empty?
287 unless categories.empty?
288 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => categories.collect{|s| [s.name, s.id.to_s] } }
288 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => categories.collect{|s| [s.name, s.id.to_s] } }
289 end
289 end
290 versions = project.shared_versions.all
290 versions = project.shared_versions.all
291 unless versions.empty?
291 unless versions.empty?
292 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
292 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
293 end
293 end
294 add_custom_fields_filters(project.all_issue_custom_fields)
294 add_custom_fields_filters(project.all_issue_custom_fields)
295 else
295 else
296 # global filters for cross project issue list
296 # global filters for cross project issue list
297 system_shared_versions = Version.visible.find_all_by_sharing('system')
297 system_shared_versions = Version.visible.find_all_by_sharing('system')
298 unless system_shared_versions.empty?
298 unless system_shared_versions.empty?
299 @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] } }
299 @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] } }
300 end
300 end
301 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
301 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
302 end
302 end
303 @available_filters
303 @available_filters
304 end
304 end
305
305
306 def add_filter(field, operator, values)
306 def add_filter(field, operator, values)
307 # values must be an array
307 # values must be an array
308 return unless values.nil? || values.is_a?(Array)
308 return unless values.nil? || values.is_a?(Array)
309 # check if field is defined as an available filter
309 # check if field is defined as an available filter
310 if available_filters.has_key? field
310 if available_filters.has_key? field
311 filter_options = available_filters[field]
311 filter_options = available_filters[field]
312 # check if operator is allowed for that filter
312 # check if operator is allowed for that filter
313 #if @@operators_by_filter_type[filter_options[:type]].include? operator
313 #if @@operators_by_filter_type[filter_options[:type]].include? operator
314 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
314 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
315 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
315 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
316 #end
316 #end
317 filters[field] = {:operator => operator, :values => (values || [''])}
317 filters[field] = {:operator => operator, :values => (values || [''])}
318 end
318 end
319 end
319 end
320
320
321 def add_short_filter(field, expression)
321 def add_short_filter(field, expression)
322 return unless expression && available_filters.has_key?(field)
322 return unless expression && available_filters.has_key?(field)
323 field_type = available_filters[field][:type]
323 field_type = available_filters[field][:type]
324 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
324 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
325 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
325 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
326 add_filter field, operator, $1.present? ? $1.split('|') : ['']
326 add_filter field, operator, $1.present? ? $1.split('|') : ['']
327 end || add_filter(field, '=', expression.split('|'))
327 end || add_filter(field, '=', expression.split('|'))
328 end
328 end
329
329
330 # Add multiple filters using +add_filter+
330 # Add multiple filters using +add_filter+
331 def add_filters(fields, operators, values)
331 def add_filters(fields, operators, values)
332 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
332 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
333 fields.each do |field|
333 fields.each do |field|
334 add_filter(field, operators[field], values && values[field])
334 add_filter(field, operators[field], values && values[field])
335 end
335 end
336 end
336 end
337 end
337 end
338
338
339 def has_filter?(field)
339 def has_filter?(field)
340 filters and filters[field]
340 filters and filters[field]
341 end
341 end
342
342
343 def type_for(field)
343 def type_for(field)
344 available_filters[field][:type] if available_filters.has_key?(field)
344 available_filters[field][:type] if available_filters.has_key?(field)
345 end
345 end
346
346
347 def operator_for(field)
347 def operator_for(field)
348 has_filter?(field) ? filters[field][:operator] : nil
348 has_filter?(field) ? filters[field][:operator] : nil
349 end
349 end
350
350
351 def values_for(field)
351 def values_for(field)
352 has_filter?(field) ? filters[field][:values] : nil
352 has_filter?(field) ? filters[field][:values] : nil
353 end
353 end
354
354
355 def value_for(field, index=0)
355 def value_for(field, index=0)
356 (values_for(field) || [])[index]
356 (values_for(field) || [])[index]
357 end
357 end
358
358
359 def label_for(field)
359 def label_for(field)
360 label = available_filters[field][:name] if available_filters.has_key?(field)
360 label = available_filters[field][:name] if available_filters.has_key?(field)
361 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
361 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
362 end
362 end
363
363
364 def available_columns
364 def available_columns
365 return @available_columns if @available_columns
365 return @available_columns if @available_columns
366 @available_columns = ::Query.available_columns.dup
366 @available_columns = ::Query.available_columns.dup
367 @available_columns += (project ?
367 @available_columns += (project ?
368 project.all_issue_custom_fields :
368 project.all_issue_custom_fields :
369 IssueCustomField.find(:all)
369 IssueCustomField.find(:all)
370 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
370 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
371
371
372 if User.current.allowed_to?(:view_time_entries, project, :global => true)
372 if User.current.allowed_to?(:view_time_entries, project, :global => true)
373 index = nil
373 index = nil
374 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
374 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
375 index = (index ? index + 1 : -1)
375 index = (index ? index + 1 : -1)
376 # insert the column after estimated_hours or at the end
376 # insert the column after estimated_hours or at the end
377 @available_columns.insert index, QueryColumn.new(:spent_hours,
377 @available_columns.insert index, QueryColumn.new(:spent_hours,
378 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
378 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
379 :default_order => 'desc',
379 :default_order => 'desc',
380 :caption => :label_spent_time
380 :caption => :label_spent_time
381 )
381 )
382 end
382 end
383 @available_columns
383 @available_columns
384 end
384 end
385
385
386 def self.available_columns=(v)
386 def self.available_columns=(v)
387 self.available_columns = (v)
387 self.available_columns = (v)
388 end
388 end
389
389
390 def self.add_available_column(column)
390 def self.add_available_column(column)
391 self.available_columns << (column) if column.is_a?(QueryColumn)
391 self.available_columns << (column) if column.is_a?(QueryColumn)
392 end
392 end
393
393
394 # Returns an array of columns that can be used to group the results
394 # Returns an array of columns that can be used to group the results
395 def groupable_columns
395 def groupable_columns
396 available_columns.select {|c| c.groupable}
396 available_columns.select {|c| c.groupable}
397 end
397 end
398
398
399 # Returns a Hash of columns and the key for sorting
399 # Returns a Hash of columns and the key for sorting
400 def sortable_columns
400 def sortable_columns
401 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
401 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
402 h[column.name.to_s] = column.sortable
402 h[column.name.to_s] = column.sortable
403 h
403 h
404 })
404 })
405 end
405 end
406
406
407 def columns
407 def columns
408 # preserve the column_names order
408 # preserve the column_names order
409 (has_default_columns? ? default_columns_names : column_names).collect do |name|
409 (has_default_columns? ? default_columns_names : column_names).collect do |name|
410 available_columns.find { |col| col.name == name }
410 available_columns.find { |col| col.name == name }
411 end.compact
411 end.compact
412 end
412 end
413
413
414 def default_columns_names
414 def default_columns_names
415 @default_columns_names ||= begin
415 @default_columns_names ||= begin
416 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
416 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
417
417
418 project.present? ? default_columns : [:project] | default_columns
418 project.present? ? default_columns : [:project] | default_columns
419 end
419 end
420 end
420 end
421
421
422 def column_names=(names)
422 def column_names=(names)
423 if names
423 if names
424 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
424 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
425 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
425 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
426 # Set column_names to nil if default columns
426 # Set column_names to nil if default columns
427 if names == default_columns_names
427 if names == default_columns_names
428 names = nil
428 names = nil
429 end
429 end
430 end
430 end
431 write_attribute(:column_names, names)
431 write_attribute(:column_names, names)
432 end
432 end
433
433
434 def has_column?(column)
434 def has_column?(column)
435 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
435 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
436 end
436 end
437
437
438 def has_default_columns?
438 def has_default_columns?
439 column_names.nil? || column_names.empty?
439 column_names.nil? || column_names.empty?
440 end
440 end
441
441
442 def sort_criteria=(arg)
442 def sort_criteria=(arg)
443 c = []
443 c = []
444 if arg.is_a?(Hash)
444 if arg.is_a?(Hash)
445 arg = arg.keys.sort.collect {|k| arg[k]}
445 arg = arg.keys.sort.collect {|k| arg[k]}
446 end
446 end
447 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
447 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
448 write_attribute(:sort_criteria, c)
448 write_attribute(:sort_criteria, c)
449 end
449 end
450
450
451 def sort_criteria
451 def sort_criteria
452 read_attribute(:sort_criteria) || []
452 read_attribute(:sort_criteria) || []
453 end
453 end
454
454
455 def sort_criteria_key(arg)
455 def sort_criteria_key(arg)
456 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
456 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
457 end
457 end
458
458
459 def sort_criteria_order(arg)
459 def sort_criteria_order(arg)
460 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
460 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
461 end
461 end
462
462
463 # Returns the SQL sort order that should be prepended for grouping
463 # Returns the SQL sort order that should be prepended for grouping
464 def group_by_sort_order
464 def group_by_sort_order
465 if grouped? && (column = group_by_column)
465 if grouped? && (column = group_by_column)
466 column.sortable.is_a?(Array) ?
466 column.sortable.is_a?(Array) ?
467 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
467 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
468 "#{column.sortable} #{column.default_order}"
468 "#{column.sortable} #{column.default_order}"
469 end
469 end
470 end
470 end
471
471
472 # Returns true if the query is a grouped query
472 # Returns true if the query is a grouped query
473 def grouped?
473 def grouped?
474 !group_by_column.nil?
474 !group_by_column.nil?
475 end
475 end
476
476
477 def group_by_column
477 def group_by_column
478 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
478 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
479 end
479 end
480
480
481 def group_by_statement
481 def group_by_statement
482 group_by_column.try(:groupable)
482 group_by_column.try(:groupable)
483 end
483 end
484
484
485 def project_statement
485 def project_statement
486 project_clauses = []
486 project_clauses = []
487 if project && !project.descendants.active.empty?
487 if project && !project.descendants.active.empty?
488 ids = [project.id]
488 ids = [project.id]
489 if has_filter?("subproject_id")
489 if has_filter?("subproject_id")
490 case operator_for("subproject_id")
490 case operator_for("subproject_id")
491 when '='
491 when '='
492 # include the selected subprojects
492 # include the selected subprojects
493 ids += values_for("subproject_id").each(&:to_i)
493 ids += values_for("subproject_id").each(&:to_i)
494 when '!*'
494 when '!*'
495 # main project only
495 # main project only
496 else
496 else
497 # all subprojects
497 # all subprojects
498 ids += project.descendants.collect(&:id)
498 ids += project.descendants.collect(&:id)
499 end
499 end
500 elsif Setting.display_subprojects_issues?
500 elsif Setting.display_subprojects_issues?
501 ids += project.descendants.collect(&:id)
501 ids += project.descendants.collect(&:id)
502 end
502 end
503 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
503 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
504 elsif project
504 elsif project
505 project_clauses << "#{Project.table_name}.id = %d" % project.id
505 project_clauses << "#{Project.table_name}.id = %d" % project.id
506 end
506 end
507 project_clauses.any? ? project_clauses.join(' AND ') : nil
507 project_clauses.any? ? project_clauses.join(' AND ') : nil
508 end
508 end
509
509
510 def statement
510 def statement
511 # filters clauses
511 # filters clauses
512 filters_clauses = []
512 filters_clauses = []
513 filters.each_key do |field|
513 filters.each_key do |field|
514 next if field == "subproject_id"
514 next if field == "subproject_id"
515 v = values_for(field).clone
515 v = values_for(field).clone
516 next unless v and !v.empty?
516 next unless v and !v.empty?
517 operator = operator_for(field)
517 operator = operator_for(field)
518
518
519 # "me" value subsitution
519 # "me" value subsitution
520 if %w(assigned_to_id author_id watcher_id).include?(field)
520 if %w(assigned_to_id author_id watcher_id).include?(field)
521 if v.delete("me")
521 if v.delete("me")
522 if User.current.logged?
522 if User.current.logged?
523 v.push(User.current.id.to_s)
523 v.push(User.current.id.to_s)
524 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
524 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
525 else
525 else
526 v.push("0")
526 v.push("0")
527 end
527 end
528 end
528 end
529 end
529 end
530
530
531 if field == 'project_id'
531 if field == 'project_id'
532 if v.delete('mine')
532 if v.delete('mine')
533 v += User.current.memberships.map(&:project_id).map(&:to_s)
533 v += User.current.memberships.map(&:project_id).map(&:to_s)
534 end
534 end
535 end
535 end
536
536
537 if field =~ /^cf_(\d+)$/
537 if field =~ /^cf_(\d+)$/
538 # custom field
538 # custom field
539 filters_clauses << sql_for_custom_field(field, operator, v, $1)
539 filters_clauses << sql_for_custom_field(field, operator, v, $1)
540 elsif respond_to?("sql_for_#{field}_field")
540 elsif respond_to?("sql_for_#{field}_field")
541 # specific statement
541 # specific statement
542 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
542 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
543 else
543 else
544 # regular field
544 # regular field
545 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
545 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
546 end
546 end
547 end if filters and valid?
547 end if filters and valid?
548
548
549 filters_clauses << project_statement
549 filters_clauses << project_statement
550 filters_clauses.reject!(&:blank?)
550 filters_clauses.reject!(&:blank?)
551
551
552 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
552 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
553 end
553 end
554
554
555 # Returns the issue count
555 # Returns the issue count
556 def issue_count
556 def issue_count
557 Issue.visible.count(:include => [:status, :project], :conditions => statement)
557 Issue.visible.count(:include => [:status, :project], :conditions => statement)
558 rescue ::ActiveRecord::StatementInvalid => e
558 rescue ::ActiveRecord::StatementInvalid => e
559 raise StatementInvalid.new(e.message)
559 raise StatementInvalid.new(e.message)
560 end
560 end
561
561
562 # Returns the issue count by group or nil if query is not grouped
562 # Returns the issue count by group or nil if query is not grouped
563 def issue_count_by_group
563 def issue_count_by_group
564 r = nil
564 r = nil
565 if grouped?
565 if grouped?
566 begin
566 begin
567 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
567 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
568 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
568 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
569 rescue ActiveRecord::RecordNotFound
569 rescue ActiveRecord::RecordNotFound
570 r = {nil => issue_count}
570 r = {nil => issue_count}
571 end
571 end
572 c = group_by_column
572 c = group_by_column
573 if c.is_a?(QueryCustomFieldColumn)
573 if c.is_a?(QueryCustomFieldColumn)
574 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
574 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
575 end
575 end
576 end
576 end
577 r
577 r
578 rescue ::ActiveRecord::StatementInvalid => e
578 rescue ::ActiveRecord::StatementInvalid => e
579 raise StatementInvalid.new(e.message)
579 raise StatementInvalid.new(e.message)
580 end
580 end
581
581
582 # Returns the issues
582 # Returns the issues
583 # Valid options are :order, :offset, :limit, :include, :conditions
583 # Valid options are :order, :offset, :limit, :include, :conditions
584 def issues(options={})
584 def issues(options={})
585 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
585 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
586 order_option = nil if order_option.blank?
586 order_option = nil if order_option.blank?
587
587
588 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
588 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
589
589
590 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
590 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
591 :conditions => statement,
591 :conditions => statement,
592 :order => order_option,
592 :order => order_option,
593 :joins => joins,
593 :joins => joins,
594 :limit => options[:limit],
594 :limit => options[:limit],
595 :offset => options[:offset]
595 :offset => options[:offset]
596
596
597 if has_column?(:spent_hours)
597 if has_column?(:spent_hours)
598 Issue.load_visible_spent_hours(issues)
598 Issue.load_visible_spent_hours(issues)
599 end
599 end
600 issues
600 issues
601 rescue ::ActiveRecord::StatementInvalid => e
601 rescue ::ActiveRecord::StatementInvalid => e
602 raise StatementInvalid.new(e.message)
602 raise StatementInvalid.new(e.message)
603 end
603 end
604
604
605 # Returns the issues ids
605 # Returns the issues ids
606 def issue_ids(options={})
606 def issue_ids(options={})
607 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
607 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
608 order_option = nil if order_option.blank?
608 order_option = nil if order_option.blank?
609
609
610 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
610 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
611
611
612 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
612 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
613 :conditions => statement,
613 :conditions => statement,
614 :order => order_option,
614 :order => order_option,
615 :joins => joins,
615 :joins => joins,
616 :limit => options[:limit],
616 :limit => options[:limit],
617 :offset => options[:offset]).find_ids
617 :offset => options[:offset]).find_ids
618 rescue ::ActiveRecord::StatementInvalid => e
618 rescue ::ActiveRecord::StatementInvalid => e
619 raise StatementInvalid.new(e.message)
619 raise StatementInvalid.new(e.message)
620 end
620 end
621
621
622 # Returns the journals
622 # Returns the journals
623 # Valid options are :order, :offset, :limit
623 # Valid options are :order, :offset, :limit
624 def journals(options={})
624 def journals(options={})
625 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
625 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
626 :conditions => statement,
626 :conditions => statement,
627 :order => options[:order],
627 :order => options[:order],
628 :limit => options[:limit],
628 :limit => options[:limit],
629 :offset => options[:offset]
629 :offset => options[:offset]
630 rescue ::ActiveRecord::StatementInvalid => e
630 rescue ::ActiveRecord::StatementInvalid => e
631 raise StatementInvalid.new(e.message)
631 raise StatementInvalid.new(e.message)
632 end
632 end
633
633
634 # Returns the versions
634 # Returns the versions
635 # Valid options are :conditions
635 # Valid options are :conditions
636 def versions(options={})
636 def versions(options={})
637 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
637 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
638 rescue ::ActiveRecord::StatementInvalid => e
638 rescue ::ActiveRecord::StatementInvalid => e
639 raise StatementInvalid.new(e.message)
639 raise StatementInvalid.new(e.message)
640 end
640 end
641
641
642 def sql_for_watcher_id_field(field, operator, value)
642 def sql_for_watcher_id_field(field, operator, value)
643 db_table = Watcher.table_name
643 db_table = Watcher.table_name
644 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
644 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
645 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
645 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
646 end
646 end
647
647
648 def sql_for_member_of_group_field(field, operator, value)
648 def sql_for_member_of_group_field(field, operator, value)
649 if operator == '*' # Any group
649 if operator == '*' # Any group
650 groups = Group.all
650 groups = Group.all
651 operator = '=' # Override the operator since we want to find by assigned_to
651 operator = '=' # Override the operator since we want to find by assigned_to
652 elsif operator == "!*"
652 elsif operator == "!*"
653 groups = Group.all
653 groups = Group.all
654 operator = '!' # Override the operator since we want to find by assigned_to
654 operator = '!' # Override the operator since we want to find by assigned_to
655 else
655 else
656 groups = Group.find_all_by_id(value)
656 groups = Group.find_all_by_id(value)
657 end
657 end
658 groups ||= []
658 groups ||= []
659
659
660 members_of_groups = groups.inject([]) {|user_ids, group|
660 members_of_groups = groups.inject([]) {|user_ids, group|
661 if group && group.user_ids.present?
661 if group && group.user_ids.present?
662 user_ids << group.user_ids
662 user_ids << group.user_ids
663 end
663 end
664 user_ids.flatten.uniq.compact
664 user_ids.flatten.uniq.compact
665 }.sort.collect(&:to_s)
665 }.sort.collect(&:to_s)
666
666
667 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
667 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
668 end
668 end
669
669
670 def sql_for_assigned_to_role_field(field, operator, value)
670 def sql_for_assigned_to_role_field(field, operator, value)
671 case operator
671 case operator
672 when "*", "!*" # Member / Not member
672 when "*", "!*" # Member / Not member
673 sw = operator == "!*" ? 'NOT' : ''
673 sw = operator == "!*" ? 'NOT' : ''
674 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
674 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
675 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
675 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
676 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
676 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
677 when "=", "!"
677 when "=", "!"
678 role_cond = value.any? ?
678 role_cond = value.any? ?
679 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
679 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
680 "1=0"
680 "1=0"
681
681
682 sw = operator == "!" ? 'NOT' : ''
682 sw = operator == "!" ? 'NOT' : ''
683 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
683 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
684 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
684 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
685 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
685 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
686 end
686 end
687 end
687 end
688
688
689 private
689 private
690
690
691 def sql_for_custom_field(field, operator, value, custom_field_id)
691 def sql_for_custom_field(field, operator, value, custom_field_id)
692 db_table = CustomValue.table_name
692 db_table = CustomValue.table_name
693 db_field = 'value'
693 db_field = 'value'
694 filter = @available_filters[field]
694 filter = @available_filters[field]
695 if filter && filter[:format] == 'user'
695 if filter && filter[:format] == 'user'
696 if value.delete('me')
696 if value.delete('me')
697 value.push User.current.id.to_s
697 value.push User.current.id.to_s
698 end
698 end
699 end
699 end
700 not_in = nil
700 not_in = nil
701 if operator == '!'
701 if operator == '!'
702 # Makes ! operator work for custom fields with multiple values
702 # Makes ! operator work for custom fields with multiple values
703 operator = '='
703 operator = '='
704 not_in = 'NOT'
704 not_in = 'NOT'
705 end
705 end
706 "#{Issue.table_name}.id #{not_in} 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 " +
706 "#{Issue.table_name}.id #{not_in} 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 " +
707 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
707 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
708 end
708 end
709
709
710 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
710 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
711 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
711 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
712 sql = ''
712 sql = ''
713 case operator
713 case operator
714 when "="
714 when "="
715 if value.any?
715 if value.any?
716 case type_for(field)
716 case type_for(field)
717 when :date, :date_past
717 when :date, :date_past
718 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
718 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
719 when :integer
719 when :integer
720 if is_custom_filter
720 if is_custom_filter
721 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
721 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
722 else
722 else
723 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
723 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
724 end
724 end
725 when :float
725 when :float
726 if is_custom_filter
726 if is_custom_filter
727 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})"
727 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})"
728 else
728 else
729 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
729 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
730 end
730 end
731 else
731 else
732 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
732 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
733 end
733 end
734 else
734 else
735 # IN an empty set
735 # IN an empty set
736 sql = "1=0"
736 sql = "1=0"
737 end
737 end
738 when "!"
738 when "!"
739 if value.any?
739 if value.any?
740 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
740 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
741 else
741 else
742 # NOT IN an empty set
742 # NOT IN an empty set
743 sql = "1=1"
743 sql = "1=1"
744 end
744 end
745 when "!*"
745 when "!*"
746 sql = "#{db_table}.#{db_field} IS NULL"
746 sql = "#{db_table}.#{db_field} IS NULL"
747 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
747 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
748 when "*"
748 when "*"
749 sql = "#{db_table}.#{db_field} IS NOT NULL"
749 sql = "#{db_table}.#{db_field} IS NOT NULL"
750 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
750 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
751 when ">="
751 when ">="
752 if [:date, :date_past].include?(type_for(field))
752 if [:date, :date_past].include?(type_for(field))
753 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
753 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
754 else
754 else
755 if is_custom_filter
755 if is_custom_filter
756 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
756 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
757 else
757 else
758 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
758 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
759 end
759 end
760 end
760 end
761 when "<="
761 when "<="
762 if [:date, :date_past].include?(type_for(field))
762 if [:date, :date_past].include?(type_for(field))
763 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
763 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
764 else
764 else
765 if is_custom_filter
765 if is_custom_filter
766 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
766 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
767 else
767 else
768 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
768 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
769 end
769 end
770 end
770 end
771 when "><"
771 when "><"
772 if [:date, :date_past].include?(type_for(field))
772 if [:date, :date_past].include?(type_for(field))
773 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
773 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
774 else
774 else
775 if is_custom_filter
775 if is_custom_filter
776 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})"
776 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})"
777 else
777 else
778 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
778 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
779 end
779 end
780 end
780 end
781 when "o"
781 when "o"
782 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
782 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
783 when "c"
783 when "c"
784 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
784 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
785 when ">t-"
785 when ">t-"
786 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
786 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
787 when "<t-"
787 when "<t-"
788 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
788 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
789 when "t-"
789 when "t-"
790 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
790 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
791 when ">t+"
791 when ">t+"
792 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
792 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
793 when "<t+"
793 when "<t+"
794 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
794 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
795 when "t+"
795 when "t+"
796 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
796 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
797 when "t"
797 when "t"
798 sql = relative_date_clause(db_table, db_field, 0, 0)
798 sql = relative_date_clause(db_table, db_field, 0, 0)
799 when "w"
799 when "w"
800 first_day_of_week = l(:general_first_day_of_week).to_i
800 first_day_of_week = l(:general_first_day_of_week).to_i
801 day_of_week = Date.today.cwday
801 day_of_week = Date.today.cwday
802 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
802 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
803 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
803 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
804 when "~"
804 when "~"
805 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
805 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
806 when "!~"
806 when "!~"
807 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
807 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
808 else
808 else
809 raise "Unknown query operator #{operator}"
809 raise "Unknown query operator #{operator}"
810 end
810 end
811
811
812 return sql
812 return sql
813 end
813 end
814
814
815 def add_custom_fields_filters(custom_fields)
815 def add_custom_fields_filters(custom_fields)
816 @available_filters ||= {}
816 @available_filters ||= {}
817
817
818 custom_fields.select(&:is_filter?).each do |field|
818 custom_fields.select(&:is_filter?).each do |field|
819 case field.field_format
819 case field.field_format
820 when "text"
820 when "text"
821 options = { :type => :text, :order => 20 }
821 options = { :type => :text, :order => 20 }
822 when "list"
822 when "list"
823 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
823 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
824 when "date"
824 when "date"
825 options = { :type => :date, :order => 20 }
825 options = { :type => :date, :order => 20 }
826 when "bool"
826 when "bool"
827 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
827 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
828 when "int"
828 when "int"
829 options = { :type => :integer, :order => 20 }
829 options = { :type => :integer, :order => 20 }
830 when "float"
830 when "float"
831 options = { :type => :float, :order => 20 }
831 options = { :type => :float, :order => 20 }
832 when "user", "version"
832 when "user", "version"
833 next unless project
833 next unless project
834 values = field.possible_values_options(project)
834 values = field.possible_values_options(project)
835 if User.current.logged? && field.field_format == 'user'
835 if User.current.logged? && field.field_format == 'user'
836 values.unshift ["<< #{l(:label_me)} >>", "me"]
836 values.unshift ["<< #{l(:label_me)} >>", "me"]
837 end
837 end
838 options = { :type => :list_optional, :values => values, :order => 20}
838 options = { :type => :list_optional, :values => values, :order => 20}
839 else
839 else
840 options = { :type => :string, :order => 20 }
840 options = { :type => :string, :order => 20 }
841 end
841 end
842 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name, :format => field.field_format })
842 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name, :format => field.field_format })
843 end
843 end
844 end
844 end
845
845
846 # Returns a SQL clause for a date or datetime field.
846 # Returns a SQL clause for a date or datetime field.
847 def date_clause(table, field, from, to)
847 def date_clause(table, field, from, to)
848 s = []
848 s = []
849 if from
849 if from
850 from_yesterday = from - 1
850 from_yesterday = from - 1
851 from_yesterday_utc = Time.gm(from_yesterday.year, from_yesterday.month, from_yesterday.day)
851 from_yesterday_utc = Time.gm(from_yesterday.year, from_yesterday.month, from_yesterday.day)
852 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_utc.end_of_day)])
852 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_utc.end_of_day)])
853 end
853 end
854 if to
854 if to
855 to_utc = Time.gm(to.year, to.month, to.day)
855 to_utc = Time.gm(to.year, to.month, to.day)
856 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_utc.end_of_day)])
856 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_utc.end_of_day)])
857 end
857 end
858 s.join(' AND ')
858 s.join(' AND ')
859 end
859 end
860
860
861 # Returns a SQL clause for a date or datetime field using relative dates.
861 # Returns a SQL clause for a date or datetime field using relative dates.
862 def relative_date_clause(table, field, days_from, days_to)
862 def relative_date_clause(table, field, days_from, days_to)
863 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
863 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
864 end
864 end
865 end
865 end
@@ -1,177 +1,177
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Role < ActiveRecord::Base
18 class Role < ActiveRecord::Base
19 # Built-in roles
19 # Built-in roles
20 BUILTIN_NON_MEMBER = 1
20 BUILTIN_NON_MEMBER = 1
21 BUILTIN_ANONYMOUS = 2
21 BUILTIN_ANONYMOUS = 2
22
22
23 ISSUES_VISIBILITY_OPTIONS = [
23 ISSUES_VISIBILITY_OPTIONS = [
24 ['all', :label_issues_visibility_all],
24 ['all', :label_issues_visibility_all],
25 ['default', :label_issues_visibility_public],
25 ['default', :label_issues_visibility_public],
26 ['own', :label_issues_visibility_own]
26 ['own', :label_issues_visibility_own]
27 ]
27 ]
28
28
29 named_scope :sorted, {:order => 'builtin, position'}
29 scope :sorted, {:order => 'builtin, position'}
30 named_scope :givable, { :conditions => "builtin = 0", :order => 'position' }
30 scope :givable, { :conditions => "builtin = 0", :order => 'position' }
31 named_scope :builtin, lambda { |*args|
31 scope :builtin, lambda { |*args|
32 compare = 'not' if args.first == true
32 compare = 'not' if args.first == true
33 { :conditions => "#{compare} builtin = 0" }
33 { :conditions => "#{compare} builtin = 0" }
34 }
34 }
35
35
36 before_destroy :check_deletable
36 before_destroy :check_deletable
37 has_many :workflows, :dependent => :delete_all do
37 has_many :workflows, :dependent => :delete_all do
38 def copy(source_role)
38 def copy(source_role)
39 Workflow.copy(nil, source_role, nil, proxy_association.owner)
39 Workflow.copy(nil, source_role, nil, proxy_association.owner)
40 end
40 end
41 end
41 end
42
42
43 has_many :member_roles, :dependent => :destroy
43 has_many :member_roles, :dependent => :destroy
44 has_many :members, :through => :member_roles
44 has_many :members, :through => :member_roles
45 acts_as_list
45 acts_as_list
46
46
47 serialize :permissions, Array
47 serialize :permissions, Array
48 attr_protected :builtin
48 attr_protected :builtin
49
49
50 validates_presence_of :name
50 validates_presence_of :name
51 validates_uniqueness_of :name
51 validates_uniqueness_of :name
52 validates_length_of :name, :maximum => 30
52 validates_length_of :name, :maximum => 30
53 validates_inclusion_of :issues_visibility,
53 validates_inclusion_of :issues_visibility,
54 :in => ISSUES_VISIBILITY_OPTIONS.collect(&:first),
54 :in => ISSUES_VISIBILITY_OPTIONS.collect(&:first),
55 :if => lambda {|role| role.respond_to?(:issues_visibility)}
55 :if => lambda {|role| role.respond_to?(:issues_visibility)}
56
56
57 def permissions
57 def permissions
58 read_attribute(:permissions) || []
58 read_attribute(:permissions) || []
59 end
59 end
60
60
61 def permissions=(perms)
61 def permissions=(perms)
62 perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
62 perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
63 write_attribute(:permissions, perms)
63 write_attribute(:permissions, perms)
64 end
64 end
65
65
66 def add_permission!(*perms)
66 def add_permission!(*perms)
67 self.permissions = [] unless permissions.is_a?(Array)
67 self.permissions = [] unless permissions.is_a?(Array)
68
68
69 permissions_will_change!
69 permissions_will_change!
70 perms.each do |p|
70 perms.each do |p|
71 p = p.to_sym
71 p = p.to_sym
72 permissions << p unless permissions.include?(p)
72 permissions << p unless permissions.include?(p)
73 end
73 end
74 save!
74 save!
75 end
75 end
76
76
77 def remove_permission!(*perms)
77 def remove_permission!(*perms)
78 return unless permissions.is_a?(Array)
78 return unless permissions.is_a?(Array)
79 permissions_will_change!
79 permissions_will_change!
80 perms.each { |p| permissions.delete(p.to_sym) }
80 perms.each { |p| permissions.delete(p.to_sym) }
81 save!
81 save!
82 end
82 end
83
83
84 # Returns true if the role has the given permission
84 # Returns true if the role has the given permission
85 def has_permission?(perm)
85 def has_permission?(perm)
86 !permissions.nil? && permissions.include?(perm.to_sym)
86 !permissions.nil? && permissions.include?(perm.to_sym)
87 end
87 end
88
88
89 def <=>(role)
89 def <=>(role)
90 role ? position <=> role.position : -1
90 role ? position <=> role.position : -1
91 end
91 end
92
92
93 def to_s
93 def to_s
94 name
94 name
95 end
95 end
96
96
97 def name
97 def name
98 case builtin
98 case builtin
99 when 1; l(:label_role_non_member, :default => read_attribute(:name))
99 when 1; l(:label_role_non_member, :default => read_attribute(:name))
100 when 2; l(:label_role_anonymous, :default => read_attribute(:name))
100 when 2; l(:label_role_anonymous, :default => read_attribute(:name))
101 else; read_attribute(:name)
101 else; read_attribute(:name)
102 end
102 end
103 end
103 end
104
104
105 # Return true if the role is a builtin role
105 # Return true if the role is a builtin role
106 def builtin?
106 def builtin?
107 self.builtin != 0
107 self.builtin != 0
108 end
108 end
109
109
110 # Return true if the role is a project member role
110 # Return true if the role is a project member role
111 def member?
111 def member?
112 !self.builtin?
112 !self.builtin?
113 end
113 end
114
114
115 # Return true if role is allowed to do the specified action
115 # Return true if role is allowed to do the specified action
116 # action can be:
116 # action can be:
117 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
117 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
118 # * a permission Symbol (eg. :edit_project)
118 # * a permission Symbol (eg. :edit_project)
119 def allowed_to?(action)
119 def allowed_to?(action)
120 if action.is_a? Hash
120 if action.is_a? Hash
121 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
121 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
122 else
122 else
123 allowed_permissions.include? action
123 allowed_permissions.include? action
124 end
124 end
125 end
125 end
126
126
127 # Return all the permissions that can be given to the role
127 # Return all the permissions that can be given to the role
128 def setable_permissions
128 def setable_permissions
129 setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
129 setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
130 setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
130 setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
131 setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
131 setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
132 setable_permissions
132 setable_permissions
133 end
133 end
134
134
135 # Find all the roles that can be given to a project member
135 # Find all the roles that can be given to a project member
136 def self.find_all_givable
136 def self.find_all_givable
137 find(:all, :conditions => {:builtin => 0}, :order => 'position')
137 find(:all, :conditions => {:builtin => 0}, :order => 'position')
138 end
138 end
139
139
140 # Return the builtin 'non member' role. If the role doesn't exist,
140 # Return the builtin 'non member' role. If the role doesn't exist,
141 # it will be created on the fly.
141 # it will be created on the fly.
142 def self.non_member
142 def self.non_member
143 find_or_create_system_role(BUILTIN_NON_MEMBER, 'Non member')
143 find_or_create_system_role(BUILTIN_NON_MEMBER, 'Non member')
144 end
144 end
145
145
146 # Return the builtin 'anonymous' role. If the role doesn't exist,
146 # Return the builtin 'anonymous' role. If the role doesn't exist,
147 # it will be created on the fly.
147 # it will be created on the fly.
148 def self.anonymous
148 def self.anonymous
149 find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous')
149 find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous')
150 end
150 end
151
151
152 private
152 private
153
153
154 def allowed_permissions
154 def allowed_permissions
155 @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
155 @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
156 end
156 end
157
157
158 def allowed_actions
158 def allowed_actions
159 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
159 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
160 end
160 end
161
161
162 def check_deletable
162 def check_deletable
163 raise "Can't delete role" if members.any?
163 raise "Can't delete role" if members.any?
164 raise "Can't delete builtin role" if builtin?
164 raise "Can't delete builtin role" if builtin?
165 end
165 end
166
166
167 def self.find_or_create_system_role(builtin, name)
167 def self.find_or_create_system_role(builtin, name)
168 role = first(:conditions => {:builtin => builtin})
168 role = first(:conditions => {:builtin => builtin})
169 if role.nil?
169 if role.nil?
170 role = create(:name => name, :position => 0) do |r|
170 role = create(:name => name, :position => 0) do |r|
171 r.builtin = builtin
171 r.builtin = builtin
172 end
172 end
173 raise "Unable to create the #{name} role." if role.new_record?
173 raise "Unable to create the #{name} role." if role.new_record?
174 end
174 end
175 role
175 role
176 end
176 end
177 end
177 end
@@ -1,120 +1,120
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class TimeEntry < ActiveRecord::Base
18 class TimeEntry < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 # could have used polymorphic association
20 # could have used polymorphic association
21 # project association here allows easy loading of time entries at project level with one database trip
21 # project association here allows easy loading of time entries at project level with one database trip
22 belongs_to :project
22 belongs_to :project
23 belongs_to :issue
23 belongs_to :issue
24 belongs_to :user
24 belongs_to :user
25 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
25 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
26
26
27 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
27 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
28
28
29 acts_as_customizable
29 acts_as_customizable
30 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
30 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
31 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
31 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
32 :author => :user,
32 :author => :user,
33 :description => :comments
33 :description => :comments
34
34
35 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
35 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
36 :author_key => :user_id,
36 :author_key => :user_id,
37 :find_options => {:include => :project}
37 :find_options => {:include => :project}
38
38
39 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
39 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
40 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
40 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
41 validates_length_of :comments, :maximum => 255, :allow_nil => true
41 validates_length_of :comments, :maximum => 255, :allow_nil => true
42 before_validation :set_project_if_nil
42 before_validation :set_project_if_nil
43 validate :validate_time_entry
43 validate :validate_time_entry
44
44
45 named_scope :visible, lambda {|*args| {
45 scope :visible, lambda {|*args| {
46 :include => :project,
46 :include => :project,
47 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
47 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
48 }}
48 }}
49 named_scope :on_issue, lambda {|issue| {
49 scope :on_issue, lambda {|issue| {
50 :include => :issue,
50 :include => :issue,
51 :conditions => "#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}"
51 :conditions => "#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}"
52 }}
52 }}
53 named_scope :on_project, lambda {|project, include_subprojects| {
53 scope :on_project, lambda {|project, include_subprojects| {
54 :include => :project,
54 :include => :project,
55 :conditions => project.project_condition(include_subprojects)
55 :conditions => project.project_condition(include_subprojects)
56 }}
56 }}
57 named_scope :spent_between, lambda {|from, to|
57 scope :spent_between, lambda {|from, to|
58 if from && to
58 if from && to
59 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
59 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
60 elsif from
60 elsif from
61 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
61 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
62 elsif to
62 elsif to
63 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
63 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
64 else
64 else
65 {}
65 {}
66 end
66 end
67 }
67 }
68
68
69 safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values'
69 safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values'
70
70
71 def initialize(attributes=nil, *args)
71 def initialize(attributes=nil, *args)
72 super
72 super
73 if new_record? && self.activity.nil?
73 if new_record? && self.activity.nil?
74 if default_activity = TimeEntryActivity.default
74 if default_activity = TimeEntryActivity.default
75 self.activity_id = default_activity.id
75 self.activity_id = default_activity.id
76 end
76 end
77 self.hours = nil if hours == 0
77 self.hours = nil if hours == 0
78 end
78 end
79 end
79 end
80
80
81 def set_project_if_nil
81 def set_project_if_nil
82 self.project = issue.project if issue && project.nil?
82 self.project = issue.project if issue && project.nil?
83 end
83 end
84
84
85 def validate_time_entry
85 def validate_time_entry
86 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
86 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
87 errors.add :project_id, :invalid if project.nil?
87 errors.add :project_id, :invalid if project.nil?
88 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
88 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
89 end
89 end
90
90
91 def hours=(h)
91 def hours=(h)
92 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
92 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
93 end
93 end
94
94
95 def hours
95 def hours
96 h = read_attribute(:hours)
96 h = read_attribute(:hours)
97 if h.is_a?(Float)
97 if h.is_a?(Float)
98 h.round(2)
98 h.round(2)
99 else
99 else
100 h
100 h
101 end
101 end
102 end
102 end
103
103
104 # tyear, tmonth, tweek assigned where setting spent_on attributes
104 # tyear, tmonth, tweek assigned where setting spent_on attributes
105 # these attributes make time aggregations easier
105 # these attributes make time aggregations easier
106 def spent_on=(date)
106 def spent_on=(date)
107 super
107 super
108 if spent_on.is_a?(Time)
108 if spent_on.is_a?(Time)
109 self.spent_on = spent_on.to_date
109 self.spent_on = spent_on.to_date
110 end
110 end
111 self.tyear = spent_on ? spent_on.year : nil
111 self.tyear = spent_on ? spent_on.year : nil
112 self.tmonth = spent_on ? spent_on.month : nil
112 self.tmonth = spent_on ? spent_on.month : nil
113 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
113 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
114 end
114 end
115
115
116 # Returns true if the time entry can be edited by usr, otherwise false
116 # Returns true if the time entry can be edited by usr, otherwise false
117 def editable_by?(usr)
117 def editable_by?(usr)
118 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
118 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
119 end
119 end
120 end
120 end
@@ -1,68 +1,68
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Tracker < ActiveRecord::Base
18 class Tracker < ActiveRecord::Base
19 before_destroy :check_integrity
19 before_destroy :check_integrity
20 has_many :issues
20 has_many :issues
21 has_many :workflows, :dependent => :delete_all do
21 has_many :workflows, :dependent => :delete_all do
22 def copy(source_tracker)
22 def copy(source_tracker)
23 Workflow.copy(source_tracker, nil, proxy_association.owner, nil)
23 Workflow.copy(source_tracker, nil, proxy_association.owner, nil)
24 end
24 end
25 end
25 end
26
26
27 has_and_belongs_to_many :projects
27 has_and_belongs_to_many :projects
28 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
28 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
29 acts_as_list
29 acts_as_list
30
30
31 validates_presence_of :name
31 validates_presence_of :name
32 validates_uniqueness_of :name
32 validates_uniqueness_of :name
33 validates_length_of :name, :maximum => 30
33 validates_length_of :name, :maximum => 30
34
34
35 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
35 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
36
36
37 def to_s; name end
37 def to_s; name end
38
38
39 def <=>(tracker)
39 def <=>(tracker)
40 name <=> tracker.name
40 name <=> tracker.name
41 end
41 end
42
42
43 def self.all
43 def self.all
44 find(:all, :order => 'position')
44 find(:all, :order => 'position')
45 end
45 end
46
46
47 # Returns an array of IssueStatus that are used
47 # Returns an array of IssueStatus that are used
48 # in the tracker's workflows
48 # in the tracker's workflows
49 def issue_statuses
49 def issue_statuses
50 if @issue_statuses
50 if @issue_statuses
51 return @issue_statuses
51 return @issue_statuses
52 elsif new_record?
52 elsif new_record?
53 return []
53 return []
54 end
54 end
55
55
56 ids = Workflow.
56 ids = Workflow.
57 connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{Workflow.table_name} WHERE tracker_id = #{id}").
57 connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{Workflow.table_name} WHERE tracker_id = #{id}").
58 flatten.
58 flatten.
59 uniq
59 uniq
60
60
61 @issue_statuses = IssueStatus.find_all_by_id(ids).sort
61 @issue_statuses = IssueStatus.find_all_by_id(ids).sort
62 end
62 end
63
63
64 private
64 private
65 def check_integrity
65 def check_integrity
66 raise "Can't delete tracker" if Issue.find(:first, :conditions => ["tracker_id=?", self.id])
66 raise "Can't delete tracker" if Issue.find(:first, :conditions => ["tracker_id=?", self.id])
67 end
67 end
68 end
68 end
@@ -1,657 +1,657
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/sha1"
18 require "digest/sha1"
19
19
20 class User < Principal
20 class User < Principal
21 include Redmine::SafeAttributes
21 include Redmine::SafeAttributes
22
22
23 # Account statuses
23 # Account statuses
24 STATUS_ANONYMOUS = 0
24 STATUS_ANONYMOUS = 0
25 STATUS_ACTIVE = 1
25 STATUS_ACTIVE = 1
26 STATUS_REGISTERED = 2
26 STATUS_REGISTERED = 2
27 STATUS_LOCKED = 3
27 STATUS_LOCKED = 3
28
28
29 # Different ways of displaying/sorting users
29 # Different ways of displaying/sorting users
30 USER_FORMATS = {
30 USER_FORMATS = {
31 :firstname_lastname => {:string => '#{firstname} #{lastname}', :order => %w(firstname lastname id)},
31 :firstname_lastname => {:string => '#{firstname} #{lastname}', :order => %w(firstname lastname id)},
32 :firstname => {:string => '#{firstname}', :order => %w(firstname id)},
32 :firstname => {:string => '#{firstname}', :order => %w(firstname id)},
33 :lastname_firstname => {:string => '#{lastname} #{firstname}', :order => %w(lastname firstname id)},
33 :lastname_firstname => {:string => '#{lastname} #{firstname}', :order => %w(lastname firstname id)},
34 :lastname_coma_firstname => {:string => '#{lastname}, #{firstname}', :order => %w(lastname firstname id)},
34 :lastname_coma_firstname => {:string => '#{lastname}, #{firstname}', :order => %w(lastname firstname id)},
35 :username => {:string => '#{login}', :order => %w(login id)},
35 :username => {:string => '#{login}', :order => %w(login id)},
36 }
36 }
37
37
38 MAIL_NOTIFICATION_OPTIONS = [
38 MAIL_NOTIFICATION_OPTIONS = [
39 ['all', :label_user_mail_option_all],
39 ['all', :label_user_mail_option_all],
40 ['selected', :label_user_mail_option_selected],
40 ['selected', :label_user_mail_option_selected],
41 ['only_my_events', :label_user_mail_option_only_my_events],
41 ['only_my_events', :label_user_mail_option_only_my_events],
42 ['only_assigned', :label_user_mail_option_only_assigned],
42 ['only_assigned', :label_user_mail_option_only_assigned],
43 ['only_owner', :label_user_mail_option_only_owner],
43 ['only_owner', :label_user_mail_option_only_owner],
44 ['none', :label_user_mail_option_none]
44 ['none', :label_user_mail_option_none]
45 ]
45 ]
46
46
47 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
47 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
48 :after_remove => Proc.new {|user, group| group.user_removed(user)}
48 :after_remove => Proc.new {|user, group| group.user_removed(user)}
49 has_many :changesets, :dependent => :nullify
49 has_many :changesets, :dependent => :nullify
50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
51 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
51 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
52 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
52 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
53 belongs_to :auth_source
53 belongs_to :auth_source
54
54
55 # Active non-anonymous users scope
55 # Active non-anonymous users scope
56 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
56 scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
57 named_scope :logged, :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}"
57 scope :logged, :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}"
58 named_scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
58 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
59
59
60 acts_as_customizable
60 acts_as_customizable
61
61
62 attr_accessor :password, :password_confirmation
62 attr_accessor :password, :password_confirmation
63 attr_accessor :last_before_login_on
63 attr_accessor :last_before_login_on
64 # Prevents unauthorized assignments
64 # Prevents unauthorized assignments
65 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
65 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
66
66
67 LOGIN_LENGTH_LIMIT = 60
67 LOGIN_LENGTH_LIMIT = 60
68 MAIL_LENGTH_LIMIT = 60
68 MAIL_LENGTH_LIMIT = 60
69
69
70 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
70 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
71 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
71 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
72 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
72 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
73 # Login must contain lettres, numbers, underscores only
73 # Login must contain lettres, numbers, underscores only
74 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
74 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
75 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
75 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
76 validates_length_of :firstname, :lastname, :maximum => 30
76 validates_length_of :firstname, :lastname, :maximum => 30
77 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
77 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
78 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
78 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
79 validates_confirmation_of :password, :allow_nil => true
79 validates_confirmation_of :password, :allow_nil => true
80 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
80 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
81 validate :validate_password_length
81 validate :validate_password_length
82
82
83 before_create :set_mail_notification
83 before_create :set_mail_notification
84 before_save :update_hashed_password
84 before_save :update_hashed_password
85 before_destroy :remove_references_before_destroy
85 before_destroy :remove_references_before_destroy
86
86
87 named_scope :in_group, lambda {|group|
87 scope :in_group, lambda {|group|
88 group_id = group.is_a?(Group) ? group.id : group.to_i
88 group_id = group.is_a?(Group) ? group.id : group.to_i
89 { :conditions => ["#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
89 { :conditions => ["#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
90 }
90 }
91 named_scope :not_in_group, lambda {|group|
91 scope :not_in_group, lambda {|group|
92 group_id = group.is_a?(Group) ? group.id : group.to_i
92 group_id = group.is_a?(Group) ? group.id : group.to_i
93 { :conditions => ["#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
93 { :conditions => ["#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
94 }
94 }
95
95
96 def set_mail_notification
96 def set_mail_notification
97 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
97 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
98 true
98 true
99 end
99 end
100
100
101 def update_hashed_password
101 def update_hashed_password
102 # update hashed_password if password was set
102 # update hashed_password if password was set
103 if self.password && self.auth_source_id.blank?
103 if self.password && self.auth_source_id.blank?
104 salt_password(password)
104 salt_password(password)
105 end
105 end
106 end
106 end
107
107
108 def reload(*args)
108 def reload(*args)
109 @name = nil
109 @name = nil
110 @projects_by_role = nil
110 @projects_by_role = nil
111 super
111 super
112 end
112 end
113
113
114 def mail=(arg)
114 def mail=(arg)
115 write_attribute(:mail, arg.to_s.strip)
115 write_attribute(:mail, arg.to_s.strip)
116 end
116 end
117
117
118 def identity_url=(url)
118 def identity_url=(url)
119 if url.blank?
119 if url.blank?
120 write_attribute(:identity_url, '')
120 write_attribute(:identity_url, '')
121 else
121 else
122 begin
122 begin
123 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
123 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
124 rescue OpenIdAuthentication::InvalidOpenId
124 rescue OpenIdAuthentication::InvalidOpenId
125 # Invlaid url, don't save
125 # Invlaid url, don't save
126 end
126 end
127 end
127 end
128 self.read_attribute(:identity_url)
128 self.read_attribute(:identity_url)
129 end
129 end
130
130
131 # Returns the user that matches provided login and password, or nil
131 # Returns the user that matches provided login and password, or nil
132 def self.try_to_login(login, password)
132 def self.try_to_login(login, password)
133 # Make sure no one can sign in with an empty password
133 # Make sure no one can sign in with an empty password
134 return nil if password.to_s.empty?
134 return nil if password.to_s.empty?
135 user = find_by_login(login)
135 user = find_by_login(login)
136 if user
136 if user
137 # user is already in local database
137 # user is already in local database
138 return nil if !user.active?
138 return nil if !user.active?
139 if user.auth_source
139 if user.auth_source
140 # user has an external authentication method
140 # user has an external authentication method
141 return nil unless user.auth_source.authenticate(login, password)
141 return nil unless user.auth_source.authenticate(login, password)
142 else
142 else
143 # authentication with local password
143 # authentication with local password
144 return nil unless user.check_password?(password)
144 return nil unless user.check_password?(password)
145 end
145 end
146 else
146 else
147 # user is not yet registered, try to authenticate with available sources
147 # user is not yet registered, try to authenticate with available sources
148 attrs = AuthSource.authenticate(login, password)
148 attrs = AuthSource.authenticate(login, password)
149 if attrs
149 if attrs
150 user = new(attrs)
150 user = new(attrs)
151 user.login = login
151 user.login = login
152 user.language = Setting.default_language
152 user.language = Setting.default_language
153 if user.save
153 if user.save
154 user.reload
154 user.reload
155 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
155 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
156 end
156 end
157 end
157 end
158 end
158 end
159 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
159 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
160 user
160 user
161 rescue => text
161 rescue => text
162 raise text
162 raise text
163 end
163 end
164
164
165 # Returns the user who matches the given autologin +key+ or nil
165 # Returns the user who matches the given autologin +key+ or nil
166 def self.try_to_autologin(key)
166 def self.try_to_autologin(key)
167 tokens = Token.find_all_by_action_and_value('autologin', key)
167 tokens = Token.find_all_by_action_and_value('autologin', key)
168 # Make sure there's only 1 token that matches the key
168 # Make sure there's only 1 token that matches the key
169 if tokens.size == 1
169 if tokens.size == 1
170 token = tokens.first
170 token = tokens.first
171 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
171 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
172 token.user.update_attribute(:last_login_on, Time.now)
172 token.user.update_attribute(:last_login_on, Time.now)
173 token.user
173 token.user
174 end
174 end
175 end
175 end
176 end
176 end
177
177
178 def self.name_formatter(formatter = nil)
178 def self.name_formatter(formatter = nil)
179 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
179 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
180 end
180 end
181
181
182 # Returns an array of fields names than can be used to make an order statement for users
182 # Returns an array of fields names than can be used to make an order statement for users
183 # according to how user names are displayed
183 # according to how user names are displayed
184 # Examples:
184 # Examples:
185 #
185 #
186 # User.fields_for_order_statement => ['users.login', 'users.id']
186 # User.fields_for_order_statement => ['users.login', 'users.id']
187 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
187 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
188 def self.fields_for_order_statement(table=nil)
188 def self.fields_for_order_statement(table=nil)
189 table ||= table_name
189 table ||= table_name
190 name_formatter[:order].map {|field| "#{table}.#{field}"}
190 name_formatter[:order].map {|field| "#{table}.#{field}"}
191 end
191 end
192
192
193 # Return user's full name for display
193 # Return user's full name for display
194 def name(formatter = nil)
194 def name(formatter = nil)
195 f = self.class.name_formatter(formatter)
195 f = self.class.name_formatter(formatter)
196 if formatter
196 if formatter
197 eval('"' + f[:string] + '"')
197 eval('"' + f[:string] + '"')
198 else
198 else
199 @name ||= eval('"' + f[:string] + '"')
199 @name ||= eval('"' + f[:string] + '"')
200 end
200 end
201 end
201 end
202
202
203 def active?
203 def active?
204 self.status == STATUS_ACTIVE
204 self.status == STATUS_ACTIVE
205 end
205 end
206
206
207 def registered?
207 def registered?
208 self.status == STATUS_REGISTERED
208 self.status == STATUS_REGISTERED
209 end
209 end
210
210
211 def locked?
211 def locked?
212 self.status == STATUS_LOCKED
212 self.status == STATUS_LOCKED
213 end
213 end
214
214
215 def activate
215 def activate
216 self.status = STATUS_ACTIVE
216 self.status = STATUS_ACTIVE
217 end
217 end
218
218
219 def register
219 def register
220 self.status = STATUS_REGISTERED
220 self.status = STATUS_REGISTERED
221 end
221 end
222
222
223 def lock
223 def lock
224 self.status = STATUS_LOCKED
224 self.status = STATUS_LOCKED
225 end
225 end
226
226
227 def activate!
227 def activate!
228 update_attribute(:status, STATUS_ACTIVE)
228 update_attribute(:status, STATUS_ACTIVE)
229 end
229 end
230
230
231 def register!
231 def register!
232 update_attribute(:status, STATUS_REGISTERED)
232 update_attribute(:status, STATUS_REGISTERED)
233 end
233 end
234
234
235 def lock!
235 def lock!
236 update_attribute(:status, STATUS_LOCKED)
236 update_attribute(:status, STATUS_LOCKED)
237 end
237 end
238
238
239 # Returns true if +clear_password+ is the correct user's password, otherwise false
239 # Returns true if +clear_password+ is the correct user's password, otherwise false
240 def check_password?(clear_password)
240 def check_password?(clear_password)
241 if auth_source_id.present?
241 if auth_source_id.present?
242 auth_source.authenticate(self.login, clear_password)
242 auth_source.authenticate(self.login, clear_password)
243 else
243 else
244 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
244 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
245 end
245 end
246 end
246 end
247
247
248 # Generates a random salt and computes hashed_password for +clear_password+
248 # Generates a random salt and computes hashed_password for +clear_password+
249 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
249 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
250 def salt_password(clear_password)
250 def salt_password(clear_password)
251 self.salt = User.generate_salt
251 self.salt = User.generate_salt
252 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
252 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
253 end
253 end
254
254
255 # Does the backend storage allow this user to change their password?
255 # Does the backend storage allow this user to change their password?
256 def change_password_allowed?
256 def change_password_allowed?
257 return true if auth_source.nil?
257 return true if auth_source.nil?
258 return auth_source.allow_password_changes?
258 return auth_source.allow_password_changes?
259 end
259 end
260
260
261 # Generate and set a random password. Useful for automated user creation
261 # Generate and set a random password. Useful for automated user creation
262 # Based on Token#generate_token_value
262 # Based on Token#generate_token_value
263 #
263 #
264 def random_password
264 def random_password
265 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
265 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
266 password = ''
266 password = ''
267 40.times { |i| password << chars[rand(chars.size-1)] }
267 40.times { |i| password << chars[rand(chars.size-1)] }
268 self.password = password
268 self.password = password
269 self.password_confirmation = password
269 self.password_confirmation = password
270 self
270 self
271 end
271 end
272
272
273 def pref
273 def pref
274 self.preference ||= UserPreference.new(:user => self)
274 self.preference ||= UserPreference.new(:user => self)
275 end
275 end
276
276
277 def time_zone
277 def time_zone
278 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
278 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
279 end
279 end
280
280
281 def wants_comments_in_reverse_order?
281 def wants_comments_in_reverse_order?
282 self.pref[:comments_sorting] == 'desc'
282 self.pref[:comments_sorting] == 'desc'
283 end
283 end
284
284
285 # Return user's RSS key (a 40 chars long string), used to access feeds
285 # Return user's RSS key (a 40 chars long string), used to access feeds
286 def rss_key
286 def rss_key
287 if rss_token.nil?
287 if rss_token.nil?
288 create_rss_token(:action => 'feeds')
288 create_rss_token(:action => 'feeds')
289 end
289 end
290 rss_token.value
290 rss_token.value
291 end
291 end
292
292
293 # Return user's API key (a 40 chars long string), used to access the API
293 # Return user's API key (a 40 chars long string), used to access the API
294 def api_key
294 def api_key
295 if api_token.nil?
295 if api_token.nil?
296 create_api_token(:action => 'api')
296 create_api_token(:action => 'api')
297 end
297 end
298 api_token.value
298 api_token.value
299 end
299 end
300
300
301 # Return an array of project ids for which the user has explicitly turned mail notifications on
301 # Return an array of project ids for which the user has explicitly turned mail notifications on
302 def notified_projects_ids
302 def notified_projects_ids
303 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
303 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
304 end
304 end
305
305
306 def notified_project_ids=(ids)
306 def notified_project_ids=(ids)
307 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
307 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
308 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
308 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
309 @notified_projects_ids = nil
309 @notified_projects_ids = nil
310 notified_projects_ids
310 notified_projects_ids
311 end
311 end
312
312
313 def valid_notification_options
313 def valid_notification_options
314 self.class.valid_notification_options(self)
314 self.class.valid_notification_options(self)
315 end
315 end
316
316
317 # Only users that belong to more than 1 project can select projects for which they are notified
317 # Only users that belong to more than 1 project can select projects for which they are notified
318 def self.valid_notification_options(user=nil)
318 def self.valid_notification_options(user=nil)
319 # Note that @user.membership.size would fail since AR ignores
319 # Note that @user.membership.size would fail since AR ignores
320 # :include association option when doing a count
320 # :include association option when doing a count
321 if user.nil? || user.memberships.length < 1
321 if user.nil? || user.memberships.length < 1
322 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
322 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
323 else
323 else
324 MAIL_NOTIFICATION_OPTIONS
324 MAIL_NOTIFICATION_OPTIONS
325 end
325 end
326 end
326 end
327
327
328 # Find a user account by matching the exact login and then a case-insensitive
328 # Find a user account by matching the exact login and then a case-insensitive
329 # version. Exact matches will be given priority.
329 # version. Exact matches will be given priority.
330 def self.find_by_login(login)
330 def self.find_by_login(login)
331 # First look for an exact match
331 # First look for an exact match
332 user = all(:conditions => {:login => login}).detect {|u| u.login == login}
332 user = all(:conditions => {:login => login}).detect {|u| u.login == login}
333 unless user
333 unless user
334 # Fail over to case-insensitive if none was found
334 # Fail over to case-insensitive if none was found
335 user = first(:conditions => ["LOWER(login) = ?", login.to_s.downcase])
335 user = first(:conditions => ["LOWER(login) = ?", login.to_s.downcase])
336 end
336 end
337 user
337 user
338 end
338 end
339
339
340 def self.find_by_rss_key(key)
340 def self.find_by_rss_key(key)
341 token = Token.find_by_value(key)
341 token = Token.find_by_value(key)
342 token && token.user.active? ? token.user : nil
342 token && token.user.active? ? token.user : nil
343 end
343 end
344
344
345 def self.find_by_api_key(key)
345 def self.find_by_api_key(key)
346 token = Token.find_by_action_and_value('api', key)
346 token = Token.find_by_action_and_value('api', key)
347 token && token.user.active? ? token.user : nil
347 token && token.user.active? ? token.user : nil
348 end
348 end
349
349
350 # Makes find_by_mail case-insensitive
350 # Makes find_by_mail case-insensitive
351 def self.find_by_mail(mail)
351 def self.find_by_mail(mail)
352 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
352 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
353 end
353 end
354
354
355 # Returns true if the default admin account can no longer be used
355 # Returns true if the default admin account can no longer be used
356 def self.default_admin_account_changed?
356 def self.default_admin_account_changed?
357 !User.active.find_by_login("admin").try(:check_password?, "admin")
357 !User.active.find_by_login("admin").try(:check_password?, "admin")
358 end
358 end
359
359
360 def to_s
360 def to_s
361 name
361 name
362 end
362 end
363
363
364 # Returns the current day according to user's time zone
364 # Returns the current day according to user's time zone
365 def today
365 def today
366 if time_zone.nil?
366 if time_zone.nil?
367 Date.today
367 Date.today
368 else
368 else
369 Time.now.in_time_zone(time_zone).to_date
369 Time.now.in_time_zone(time_zone).to_date
370 end
370 end
371 end
371 end
372
372
373 def logged?
373 def logged?
374 true
374 true
375 end
375 end
376
376
377 def anonymous?
377 def anonymous?
378 !logged?
378 !logged?
379 end
379 end
380
380
381 # Return user's roles for project
381 # Return user's roles for project
382 def roles_for_project(project)
382 def roles_for_project(project)
383 roles = []
383 roles = []
384 # No role on archived projects
384 # No role on archived projects
385 return roles unless project && project.active?
385 return roles unless project && project.active?
386 if logged?
386 if logged?
387 # Find project membership
387 # Find project membership
388 membership = memberships.detect {|m| m.project_id == project.id}
388 membership = memberships.detect {|m| m.project_id == project.id}
389 if membership
389 if membership
390 roles = membership.roles
390 roles = membership.roles
391 else
391 else
392 @role_non_member ||= Role.non_member
392 @role_non_member ||= Role.non_member
393 roles << @role_non_member
393 roles << @role_non_member
394 end
394 end
395 else
395 else
396 @role_anonymous ||= Role.anonymous
396 @role_anonymous ||= Role.anonymous
397 roles << @role_anonymous
397 roles << @role_anonymous
398 end
398 end
399 roles
399 roles
400 end
400 end
401
401
402 # Return true if the user is a member of project
402 # Return true if the user is a member of project
403 def member_of?(project)
403 def member_of?(project)
404 !roles_for_project(project).detect {|role| role.member?}.nil?
404 !roles_for_project(project).detect {|role| role.member?}.nil?
405 end
405 end
406
406
407 # Returns a hash of user's projects grouped by roles
407 # Returns a hash of user's projects grouped by roles
408 def projects_by_role
408 def projects_by_role
409 return @projects_by_role if @projects_by_role
409 return @projects_by_role if @projects_by_role
410
410
411 @projects_by_role = Hash.new {|h,k| h[k]=[]}
411 @projects_by_role = Hash.new {|h,k| h[k]=[]}
412 memberships.each do |membership|
412 memberships.each do |membership|
413 membership.roles.each do |role|
413 membership.roles.each do |role|
414 @projects_by_role[role] << membership.project if membership.project
414 @projects_by_role[role] << membership.project if membership.project
415 end
415 end
416 end
416 end
417 @projects_by_role.each do |role, projects|
417 @projects_by_role.each do |role, projects|
418 projects.uniq!
418 projects.uniq!
419 end
419 end
420
420
421 @projects_by_role
421 @projects_by_role
422 end
422 end
423
423
424 # Returns true if user is arg or belongs to arg
424 # Returns true if user is arg or belongs to arg
425 def is_or_belongs_to?(arg)
425 def is_or_belongs_to?(arg)
426 if arg.is_a?(User)
426 if arg.is_a?(User)
427 self == arg
427 self == arg
428 elsif arg.is_a?(Group)
428 elsif arg.is_a?(Group)
429 arg.users.include?(self)
429 arg.users.include?(self)
430 else
430 else
431 false
431 false
432 end
432 end
433 end
433 end
434
434
435 # Return true if the user is allowed to do the specified action on a specific context
435 # Return true if the user is allowed to do the specified action on a specific context
436 # Action can be:
436 # Action can be:
437 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
437 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
438 # * a permission Symbol (eg. :edit_project)
438 # * a permission Symbol (eg. :edit_project)
439 # Context can be:
439 # Context can be:
440 # * a project : returns true if user is allowed to do the specified action on this project
440 # * a project : returns true if user is allowed to do the specified action on this project
441 # * an array of projects : returns true if user is allowed on every project
441 # * an array of projects : returns true if user is allowed on every project
442 # * nil with options[:global] set : check if user has at least one role allowed for this action,
442 # * nil with options[:global] set : check if user has at least one role allowed for this action,
443 # or falls back to Non Member / Anonymous permissions depending if the user is logged
443 # or falls back to Non Member / Anonymous permissions depending if the user is logged
444 def allowed_to?(action, context, options={}, &block)
444 def allowed_to?(action, context, options={}, &block)
445 if context && context.is_a?(Project)
445 if context && context.is_a?(Project)
446 # No action allowed on archived projects
446 # No action allowed on archived projects
447 return false unless context.active?
447 return false unless context.active?
448 # No action allowed on disabled modules
448 # No action allowed on disabled modules
449 return false unless context.allows_to?(action)
449 return false unless context.allows_to?(action)
450 # Admin users are authorized for anything else
450 # Admin users are authorized for anything else
451 return true if admin?
451 return true if admin?
452
452
453 roles = roles_for_project(context)
453 roles = roles_for_project(context)
454 return false unless roles
454 return false unless roles
455 roles.detect {|role|
455 roles.detect {|role|
456 (context.is_public? || role.member?) &&
456 (context.is_public? || role.member?) &&
457 role.allowed_to?(action) &&
457 role.allowed_to?(action) &&
458 (block_given? ? yield(role, self) : true)
458 (block_given? ? yield(role, self) : true)
459 }
459 }
460 elsif context && context.is_a?(Array)
460 elsif context && context.is_a?(Array)
461 # Authorize if user is authorized on every element of the array
461 # Authorize if user is authorized on every element of the array
462 context.map do |project|
462 context.map do |project|
463 allowed_to?(action, project, options, &block)
463 allowed_to?(action, project, options, &block)
464 end.inject do |memo,allowed|
464 end.inject do |memo,allowed|
465 memo && allowed
465 memo && allowed
466 end
466 end
467 elsif options[:global]
467 elsif options[:global]
468 # Admin users are always authorized
468 # Admin users are always authorized
469 return true if admin?
469 return true if admin?
470
470
471 # authorize if user has at least one role that has this permission
471 # authorize if user has at least one role that has this permission
472 roles = memberships.collect {|m| m.roles}.flatten.uniq
472 roles = memberships.collect {|m| m.roles}.flatten.uniq
473 roles << (self.logged? ? Role.non_member : Role.anonymous)
473 roles << (self.logged? ? Role.non_member : Role.anonymous)
474 roles.detect {|role|
474 roles.detect {|role|
475 role.allowed_to?(action) &&
475 role.allowed_to?(action) &&
476 (block_given? ? yield(role, self) : true)
476 (block_given? ? yield(role, self) : true)
477 }
477 }
478 else
478 else
479 false
479 false
480 end
480 end
481 end
481 end
482
482
483 # Is the user allowed to do the specified action on any project?
483 # Is the user allowed to do the specified action on any project?
484 # See allowed_to? for the actions and valid options.
484 # See allowed_to? for the actions and valid options.
485 def allowed_to_globally?(action, options, &block)
485 def allowed_to_globally?(action, options, &block)
486 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
486 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
487 end
487 end
488
488
489 # Returns true if the user is allowed to delete his own account
489 # Returns true if the user is allowed to delete his own account
490 def own_account_deletable?
490 def own_account_deletable?
491 Setting.unsubscribe? &&
491 Setting.unsubscribe? &&
492 (!admin? || User.active.first(:conditions => ["admin = ? AND id <> ?", true, id]).present?)
492 (!admin? || User.active.first(:conditions => ["admin = ? AND id <> ?", true, id]).present?)
493 end
493 end
494
494
495 safe_attributes 'login',
495 safe_attributes 'login',
496 'firstname',
496 'firstname',
497 'lastname',
497 'lastname',
498 'mail',
498 'mail',
499 'mail_notification',
499 'mail_notification',
500 'language',
500 'language',
501 'custom_field_values',
501 'custom_field_values',
502 'custom_fields',
502 'custom_fields',
503 'identity_url'
503 'identity_url'
504
504
505 safe_attributes 'status',
505 safe_attributes 'status',
506 'auth_source_id',
506 'auth_source_id',
507 :if => lambda {|user, current_user| current_user.admin?}
507 :if => lambda {|user, current_user| current_user.admin?}
508
508
509 safe_attributes 'group_ids',
509 safe_attributes 'group_ids',
510 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
510 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
511
511
512 # Utility method to help check if a user should be notified about an
512 # Utility method to help check if a user should be notified about an
513 # event.
513 # event.
514 #
514 #
515 # TODO: only supports Issue events currently
515 # TODO: only supports Issue events currently
516 def notify_about?(object)
516 def notify_about?(object)
517 case mail_notification
517 case mail_notification
518 when 'all'
518 when 'all'
519 true
519 true
520 when 'selected'
520 when 'selected'
521 # user receives notifications for created/assigned issues on unselected projects
521 # user receives notifications for created/assigned issues on unselected projects
522 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
522 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
523 true
523 true
524 else
524 else
525 false
525 false
526 end
526 end
527 when 'none'
527 when 'none'
528 false
528 false
529 when 'only_my_events'
529 when 'only_my_events'
530 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
530 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
531 true
531 true
532 else
532 else
533 false
533 false
534 end
534 end
535 when 'only_assigned'
535 when 'only_assigned'
536 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
536 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
537 true
537 true
538 else
538 else
539 false
539 false
540 end
540 end
541 when 'only_owner'
541 when 'only_owner'
542 if object.is_a?(Issue) && object.author == self
542 if object.is_a?(Issue) && object.author == self
543 true
543 true
544 else
544 else
545 false
545 false
546 end
546 end
547 else
547 else
548 false
548 false
549 end
549 end
550 end
550 end
551
551
552 def self.current=(user)
552 def self.current=(user)
553 @current_user = user
553 @current_user = user
554 end
554 end
555
555
556 def self.current
556 def self.current
557 @current_user ||= User.anonymous
557 @current_user ||= User.anonymous
558 end
558 end
559
559
560 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
560 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
561 # one anonymous user per database.
561 # one anonymous user per database.
562 def self.anonymous
562 def self.anonymous
563 anonymous_user = AnonymousUser.find(:first)
563 anonymous_user = AnonymousUser.find(:first)
564 if anonymous_user.nil?
564 if anonymous_user.nil?
565 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
565 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
566 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
566 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
567 end
567 end
568 anonymous_user
568 anonymous_user
569 end
569 end
570
570
571 # Salts all existing unsalted passwords
571 # Salts all existing unsalted passwords
572 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
572 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
573 # This method is used in the SaltPasswords migration and is to be kept as is
573 # This method is used in the SaltPasswords migration and is to be kept as is
574 def self.salt_unsalted_passwords!
574 def self.salt_unsalted_passwords!
575 transaction do
575 transaction do
576 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
576 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
577 next if user.hashed_password.blank?
577 next if user.hashed_password.blank?
578 salt = User.generate_salt
578 salt = User.generate_salt
579 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
579 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
580 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
580 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
581 end
581 end
582 end
582 end
583 end
583 end
584
584
585 protected
585 protected
586
586
587 def validate_password_length
587 def validate_password_length
588 # Password length validation based on setting
588 # Password length validation based on setting
589 if !password.nil? && password.size < Setting.password_min_length.to_i
589 if !password.nil? && password.size < Setting.password_min_length.to_i
590 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
590 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
591 end
591 end
592 end
592 end
593
593
594 private
594 private
595
595
596 # Removes references that are not handled by associations
596 # Removes references that are not handled by associations
597 # Things that are not deleted are reassociated with the anonymous user
597 # Things that are not deleted are reassociated with the anonymous user
598 def remove_references_before_destroy
598 def remove_references_before_destroy
599 return if self.id.nil?
599 return if self.id.nil?
600
600
601 substitute = User.anonymous
601 substitute = User.anonymous
602 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
602 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
603 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
603 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
604 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
604 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
605 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
605 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
606 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
606 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
607 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
607 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
608 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
608 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
609 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
609 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
610 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
610 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
611 # Remove private queries and keep public ones
611 # Remove private queries and keep public ones
612 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
612 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
613 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
613 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
614 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
614 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
615 Token.delete_all ['user_id = ?', id]
615 Token.delete_all ['user_id = ?', id]
616 Watcher.delete_all ['user_id = ?', id]
616 Watcher.delete_all ['user_id = ?', id]
617 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
617 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
618 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
618 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
619 end
619 end
620
620
621 # Return password digest
621 # Return password digest
622 def self.hash_password(clear_password)
622 def self.hash_password(clear_password)
623 Digest::SHA1.hexdigest(clear_password || "")
623 Digest::SHA1.hexdigest(clear_password || "")
624 end
624 end
625
625
626 # Returns a 128bits random salt as a hex string (32 chars long)
626 # Returns a 128bits random salt as a hex string (32 chars long)
627 def self.generate_salt
627 def self.generate_salt
628 Redmine::Utils.random_hex(16)
628 Redmine::Utils.random_hex(16)
629 end
629 end
630
630
631 end
631 end
632
632
633 class AnonymousUser < User
633 class AnonymousUser < User
634 validate :validate_anonymous_uniqueness, :on => :create
634 validate :validate_anonymous_uniqueness, :on => :create
635
635
636 def validate_anonymous_uniqueness
636 def validate_anonymous_uniqueness
637 # There should be only one AnonymousUser in the database
637 # There should be only one AnonymousUser in the database
638 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.find(:first)
638 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.find(:first)
639 end
639 end
640
640
641 def available_custom_fields
641 def available_custom_fields
642 []
642 []
643 end
643 end
644
644
645 # Overrides a few properties
645 # Overrides a few properties
646 def logged?; false end
646 def logged?; false end
647 def admin; false end
647 def admin; false end
648 def name(*args); I18n.t(:label_user_anonymous) end
648 def name(*args); I18n.t(:label_user_anonymous) end
649 def mail; nil end
649 def mail; nil end
650 def time_zone; nil end
650 def time_zone; nil end
651 def rss_key; nil end
651 def rss_key; nil end
652
652
653 # Anonymous user can not be destroyed
653 # Anonymous user can not be destroyed
654 def destroy
654 def destroy
655 false
655 false
656 end
656 end
657 end
657 end
@@ -1,271 +1,271
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Version < ActiveRecord::Base
18 class Version < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 after_update :update_issues_from_sharing_change
20 after_update :update_issues_from_sharing_change
21 belongs_to :project
21 belongs_to :project
22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
23 acts_as_customizable
23 acts_as_customizable
24 acts_as_attachable :view_permission => :view_files,
24 acts_as_attachable :view_permission => :view_files,
25 :delete_permission => :manage_files
25 :delete_permission => :manage_files
26
26
27 VERSION_STATUSES = %w(open locked closed)
27 VERSION_STATUSES = %w(open locked closed)
28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29
29
30 validates_presence_of :name
30 validates_presence_of :name
31 validates_uniqueness_of :name, :scope => [:project_id]
31 validates_uniqueness_of :name, :scope => [:project_id]
32 validates_length_of :name, :maximum => 60
32 validates_length_of :name, :maximum => 60
33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
34 validates_inclusion_of :status, :in => VERSION_STATUSES
34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36
36
37 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
37 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
38 named_scope :open, :conditions => {:status => 'open'}
38 scope :open, :conditions => {:status => 'open'}
39 named_scope :visible, lambda {|*args| { :include => :project,
39 scope :visible, lambda {|*args| { :include => :project,
40 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
40 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
41
41
42 safe_attributes 'name',
42 safe_attributes 'name',
43 'description',
43 'description',
44 'effective_date',
44 'effective_date',
45 'due_date',
45 'due_date',
46 'wiki_page_title',
46 'wiki_page_title',
47 'status',
47 'status',
48 'sharing',
48 'sharing',
49 'custom_field_values'
49 'custom_field_values'
50
50
51 # Returns true if +user+ or current user is allowed to view the version
51 # Returns true if +user+ or current user is allowed to view the version
52 def visible?(user=User.current)
52 def visible?(user=User.current)
53 user.allowed_to?(:view_issues, self.project)
53 user.allowed_to?(:view_issues, self.project)
54 end
54 end
55
55
56 # Version files have same visibility as project files
56 # Version files have same visibility as project files
57 def attachments_visible?(*args)
57 def attachments_visible?(*args)
58 project.present? && project.attachments_visible?(*args)
58 project.present? && project.attachments_visible?(*args)
59 end
59 end
60
60
61 def start_date
61 def start_date
62 @start_date ||= fixed_issues.minimum('start_date')
62 @start_date ||= fixed_issues.minimum('start_date')
63 end
63 end
64
64
65 def due_date
65 def due_date
66 effective_date
66 effective_date
67 end
67 end
68
68
69 def due_date=(arg)
69 def due_date=(arg)
70 self.effective_date=(arg)
70 self.effective_date=(arg)
71 end
71 end
72
72
73 # Returns the total estimated time for this version
73 # Returns the total estimated time for this version
74 # (sum of leaves estimated_hours)
74 # (sum of leaves estimated_hours)
75 def estimated_hours
75 def estimated_hours
76 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
76 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
77 end
77 end
78
78
79 # Returns the total reported time for this version
79 # Returns the total reported time for this version
80 def spent_hours
80 def spent_hours
81 @spent_hours ||= TimeEntry.sum(:hours, :joins => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
81 @spent_hours ||= TimeEntry.sum(:hours, :joins => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
82 end
82 end
83
83
84 def closed?
84 def closed?
85 status == 'closed'
85 status == 'closed'
86 end
86 end
87
87
88 def open?
88 def open?
89 status == 'open'
89 status == 'open'
90 end
90 end
91
91
92 # Returns true if the version is completed: due date reached and no open issues
92 # Returns true if the version is completed: due date reached and no open issues
93 def completed?
93 def completed?
94 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
94 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
95 end
95 end
96
96
97 def behind_schedule?
97 def behind_schedule?
98 if completed_pourcent == 100
98 if completed_pourcent == 100
99 return false
99 return false
100 elsif due_date && start_date
100 elsif due_date && start_date
101 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
101 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
102 return done_date <= Date.today
102 return done_date <= Date.today
103 else
103 else
104 false # No issues so it's not late
104 false # No issues so it's not late
105 end
105 end
106 end
106 end
107
107
108 # Returns the completion percentage of this version based on the amount of open/closed issues
108 # Returns the completion percentage of this version based on the amount of open/closed issues
109 # and the time spent on the open issues.
109 # and the time spent on the open issues.
110 def completed_pourcent
110 def completed_pourcent
111 if issues_count == 0
111 if issues_count == 0
112 0
112 0
113 elsif open_issues_count == 0
113 elsif open_issues_count == 0
114 100
114 100
115 else
115 else
116 issues_progress(false) + issues_progress(true)
116 issues_progress(false) + issues_progress(true)
117 end
117 end
118 end
118 end
119
119
120 # Returns the percentage of issues that have been marked as 'closed'.
120 # Returns the percentage of issues that have been marked as 'closed'.
121 def closed_pourcent
121 def closed_pourcent
122 if issues_count == 0
122 if issues_count == 0
123 0
123 0
124 else
124 else
125 issues_progress(false)
125 issues_progress(false)
126 end
126 end
127 end
127 end
128
128
129 # Returns true if the version is overdue: due date reached and some open issues
129 # Returns true if the version is overdue: due date reached and some open issues
130 def overdue?
130 def overdue?
131 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
131 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
132 end
132 end
133
133
134 # Returns assigned issues count
134 # Returns assigned issues count
135 def issues_count
135 def issues_count
136 load_issue_counts
136 load_issue_counts
137 @issue_count
137 @issue_count
138 end
138 end
139
139
140 # Returns the total amount of open issues for this version.
140 # Returns the total amount of open issues for this version.
141 def open_issues_count
141 def open_issues_count
142 load_issue_counts
142 load_issue_counts
143 @open_issues_count
143 @open_issues_count
144 end
144 end
145
145
146 # Returns the total amount of closed issues for this version.
146 # Returns the total amount of closed issues for this version.
147 def closed_issues_count
147 def closed_issues_count
148 load_issue_counts
148 load_issue_counts
149 @closed_issues_count
149 @closed_issues_count
150 end
150 end
151
151
152 def wiki_page
152 def wiki_page
153 if project.wiki && !wiki_page_title.blank?
153 if project.wiki && !wiki_page_title.blank?
154 @wiki_page ||= project.wiki.find_page(wiki_page_title)
154 @wiki_page ||= project.wiki.find_page(wiki_page_title)
155 end
155 end
156 @wiki_page
156 @wiki_page
157 end
157 end
158
158
159 def to_s; name end
159 def to_s; name end
160
160
161 def to_s_with_project
161 def to_s_with_project
162 "#{project} - #{name}"
162 "#{project} - #{name}"
163 end
163 end
164
164
165 # Versions are sorted by effective_date and "Project Name - Version name"
165 # Versions are sorted by effective_date and "Project Name - Version name"
166 # Those with no effective_date are at the end, sorted by "Project Name - Version name"
166 # Those with no effective_date are at the end, sorted by "Project Name - Version name"
167 def <=>(version)
167 def <=>(version)
168 if self.effective_date
168 if self.effective_date
169 if version.effective_date
169 if version.effective_date
170 if self.effective_date == version.effective_date
170 if self.effective_date == version.effective_date
171 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
171 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
172 else
172 else
173 self.effective_date <=> version.effective_date
173 self.effective_date <=> version.effective_date
174 end
174 end
175 else
175 else
176 -1
176 -1
177 end
177 end
178 else
178 else
179 if version.effective_date
179 if version.effective_date
180 1
180 1
181 else
181 else
182 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
182 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
183 end
183 end
184 end
184 end
185 end
185 end
186
186
187 # Returns the sharings that +user+ can set the version to
187 # Returns the sharings that +user+ can set the version to
188 def allowed_sharings(user = User.current)
188 def allowed_sharings(user = User.current)
189 VERSION_SHARINGS.select do |s|
189 VERSION_SHARINGS.select do |s|
190 if sharing == s
190 if sharing == s
191 true
191 true
192 else
192 else
193 case s
193 case s
194 when 'system'
194 when 'system'
195 # Only admin users can set a systemwide sharing
195 # Only admin users can set a systemwide sharing
196 user.admin?
196 user.admin?
197 when 'hierarchy', 'tree'
197 when 'hierarchy', 'tree'
198 # Only users allowed to manage versions of the root project can
198 # Only users allowed to manage versions of the root project can
199 # set sharing to hierarchy or tree
199 # set sharing to hierarchy or tree
200 project.nil? || user.allowed_to?(:manage_versions, project.root)
200 project.nil? || user.allowed_to?(:manage_versions, project.root)
201 else
201 else
202 true
202 true
203 end
203 end
204 end
204 end
205 end
205 end
206 end
206 end
207
207
208 private
208 private
209
209
210 def load_issue_counts
210 def load_issue_counts
211 unless @issue_count
211 unless @issue_count
212 @open_issues_count = 0
212 @open_issues_count = 0
213 @closed_issues_count = 0
213 @closed_issues_count = 0
214 fixed_issues.count(:all, :group => :status).each do |status, count|
214 fixed_issues.count(:all, :group => :status).each do |status, count|
215 if status.is_closed?
215 if status.is_closed?
216 @closed_issues_count += count
216 @closed_issues_count += count
217 else
217 else
218 @open_issues_count += count
218 @open_issues_count += count
219 end
219 end
220 end
220 end
221 @issue_count = @open_issues_count + @closed_issues_count
221 @issue_count = @open_issues_count + @closed_issues_count
222 end
222 end
223 end
223 end
224
224
225 # Update the issue's fixed versions. Used if a version's sharing changes.
225 # Update the issue's fixed versions. Used if a version's sharing changes.
226 def update_issues_from_sharing_change
226 def update_issues_from_sharing_change
227 if sharing_changed?
227 if sharing_changed?
228 if VERSION_SHARINGS.index(sharing_was).nil? ||
228 if VERSION_SHARINGS.index(sharing_was).nil? ||
229 VERSION_SHARINGS.index(sharing).nil? ||
229 VERSION_SHARINGS.index(sharing).nil? ||
230 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
230 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
231 Issue.update_versions_from_sharing_change self
231 Issue.update_versions_from_sharing_change self
232 end
232 end
233 end
233 end
234 end
234 end
235
235
236 # Returns the average estimated time of assigned issues
236 # Returns the average estimated time of assigned issues
237 # or 1 if no issue has an estimated time
237 # or 1 if no issue has an estimated time
238 # Used to weigth unestimated issues in progress calculation
238 # Used to weigth unestimated issues in progress calculation
239 def estimated_average
239 def estimated_average
240 if @estimated_average.nil?
240 if @estimated_average.nil?
241 average = fixed_issues.average(:estimated_hours).to_f
241 average = fixed_issues.average(:estimated_hours).to_f
242 if average == 0
242 if average == 0
243 average = 1
243 average = 1
244 end
244 end
245 @estimated_average = average
245 @estimated_average = average
246 end
246 end
247 @estimated_average
247 @estimated_average
248 end
248 end
249
249
250 # Returns the total progress of open or closed issues. The returned percentage takes into account
250 # Returns the total progress of open or closed issues. The returned percentage takes into account
251 # the amount of estimated time set for this version.
251 # the amount of estimated time set for this version.
252 #
252 #
253 # Examples:
253 # Examples:
254 # issues_progress(true) => returns the progress percentage for open issues.
254 # issues_progress(true) => returns the progress percentage for open issues.
255 # issues_progress(false) => returns the progress percentage for closed issues.
255 # issues_progress(false) => returns the progress percentage for closed issues.
256 def issues_progress(open)
256 def issues_progress(open)
257 @issues_progress ||= {}
257 @issues_progress ||= {}
258 @issues_progress[open] ||= begin
258 @issues_progress[open] ||= begin
259 progress = 0
259 progress = 0
260 if issues_count > 0
260 if issues_count > 0
261 ratio = open ? 'done_ratio' : 100
261 ratio = open ? 'done_ratio' : 100
262
262
263 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
263 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
264 :joins => :status,
264 :joins => :status,
265 :conditions => ["#{IssueStatus.table_name}.is_closed = ?", !open]).to_f
265 :conditions => ["#{IssueStatus.table_name}.is_closed = ?", !open]).to_f
266 progress = done / (estimated_average * issues_count)
266 progress = done / (estimated_average * issues_count)
267 end
267 end
268 progress
268 progress
269 end
269 end
270 end
270 end
271 end
271 end
@@ -1,234 +1,234
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'diff'
18 require 'diff'
19 require 'enumerator'
19 require 'enumerator'
20
20
21 class WikiPage < ActiveRecord::Base
21 class WikiPage < ActiveRecord::Base
22 include Redmine::SafeAttributes
22 include Redmine::SafeAttributes
23
23
24 belongs_to :wiki
24 belongs_to :wiki
25 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
25 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
26 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
26 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
27 acts_as_tree :dependent => :nullify, :order => 'title'
27 acts_as_tree :dependent => :nullify, :order => 'title'
28
28
29 acts_as_watchable
29 acts_as_watchable
30 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
30 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
31 :description => :text,
31 :description => :text,
32 :datetime => :created_on,
32 :datetime => :created_on,
33 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
33 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
34
34
35 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
35 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
36 :include => [{:wiki => :project}, :content],
36 :include => [{:wiki => :project}, :content],
37 :permission => :view_wiki_pages,
37 :permission => :view_wiki_pages,
38 :project_key => "#{Wiki.table_name}.project_id"
38 :project_key => "#{Wiki.table_name}.project_id"
39
39
40 attr_accessor :redirect_existing_links
40 attr_accessor :redirect_existing_links
41
41
42 validates_presence_of :title
42 validates_presence_of :title
43 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
43 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
44 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
44 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
45 validates_associated :content
45 validates_associated :content
46
46
47 validate :validate_parent_title
47 validate :validate_parent_title
48 before_destroy :remove_redirects
48 before_destroy :remove_redirects
49 before_save :handle_redirects
49 before_save :handle_redirects
50
50
51 # eager load information about last updates, without loading text
51 # eager load information about last updates, without loading text
52 named_scope :with_updated_on, {
52 scope :with_updated_on, {
53 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on",
53 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on",
54 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
54 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
55 }
55 }
56
56
57 # Wiki pages that are protected by default
57 # Wiki pages that are protected by default
58 DEFAULT_PROTECTED_PAGES = %w(sidebar)
58 DEFAULT_PROTECTED_PAGES = %w(sidebar)
59
59
60 safe_attributes 'parent_id',
60 safe_attributes 'parent_id',
61 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
61 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
62
62
63 def initialize(attributes=nil, *args)
63 def initialize(attributes=nil, *args)
64 super
64 super
65 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
65 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
66 self.protected = true
66 self.protected = true
67 end
67 end
68 end
68 end
69
69
70 def visible?(user=User.current)
70 def visible?(user=User.current)
71 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
71 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
72 end
72 end
73
73
74 def title=(value)
74 def title=(value)
75 value = Wiki.titleize(value)
75 value = Wiki.titleize(value)
76 @previous_title = read_attribute(:title) if @previous_title.blank?
76 @previous_title = read_attribute(:title) if @previous_title.blank?
77 write_attribute(:title, value)
77 write_attribute(:title, value)
78 end
78 end
79
79
80 def handle_redirects
80 def handle_redirects
81 self.title = Wiki.titleize(title)
81 self.title = Wiki.titleize(title)
82 # Manage redirects if the title has changed
82 # Manage redirects if the title has changed
83 if !@previous_title.blank? && (@previous_title != title) && !new_record?
83 if !@previous_title.blank? && (@previous_title != title) && !new_record?
84 # Update redirects that point to the old title
84 # Update redirects that point to the old title
85 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
85 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
86 r.redirects_to = title
86 r.redirects_to = title
87 r.title == r.redirects_to ? r.destroy : r.save
87 r.title == r.redirects_to ? r.destroy : r.save
88 end
88 end
89 # Remove redirects for the new title
89 # Remove redirects for the new title
90 wiki.redirects.find_all_by_title(title).each(&:destroy)
90 wiki.redirects.find_all_by_title(title).each(&:destroy)
91 # Create a redirect to the new title
91 # Create a redirect to the new title
92 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
92 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
93 @previous_title = nil
93 @previous_title = nil
94 end
94 end
95 end
95 end
96
96
97 def remove_redirects
97 def remove_redirects
98 # Remove redirects to this page
98 # Remove redirects to this page
99 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
99 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
100 end
100 end
101
101
102 def pretty_title
102 def pretty_title
103 WikiPage.pretty_title(title)
103 WikiPage.pretty_title(title)
104 end
104 end
105
105
106 def content_for_version(version=nil)
106 def content_for_version(version=nil)
107 result = content.versions.find_by_version(version.to_i) if version
107 result = content.versions.find_by_version(version.to_i) if version
108 result ||= content
108 result ||= content
109 result
109 result
110 end
110 end
111
111
112 def diff(version_to=nil, version_from=nil)
112 def diff(version_to=nil, version_from=nil)
113 version_to = version_to ? version_to.to_i : self.content.version
113 version_to = version_to ? version_to.to_i : self.content.version
114 version_from = version_from ? version_from.to_i : version_to - 1
114 version_from = version_from ? version_from.to_i : version_to - 1
115 version_to, version_from = version_from, version_to unless version_from < version_to
115 version_to, version_from = version_from, version_to unless version_from < version_to
116
116
117 content_to = content.versions.find_by_version(version_to)
117 content_to = content.versions.find_by_version(version_to)
118 content_from = content.versions.find_by_version(version_from)
118 content_from = content.versions.find_by_version(version_from)
119
119
120 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
120 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
121 end
121 end
122
122
123 def annotate(version=nil)
123 def annotate(version=nil)
124 version = version ? version.to_i : self.content.version
124 version = version ? version.to_i : self.content.version
125 c = content.versions.find_by_version(version)
125 c = content.versions.find_by_version(version)
126 c ? WikiAnnotate.new(c) : nil
126 c ? WikiAnnotate.new(c) : nil
127 end
127 end
128
128
129 def self.pretty_title(str)
129 def self.pretty_title(str)
130 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
130 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
131 end
131 end
132
132
133 def project
133 def project
134 wiki.project
134 wiki.project
135 end
135 end
136
136
137 def text
137 def text
138 content.text if content
138 content.text if content
139 end
139 end
140
140
141 def updated_on
141 def updated_on
142 unless @updated_on
142 unless @updated_on
143 if time = read_attribute(:updated_on)
143 if time = read_attribute(:updated_on)
144 # content updated_on was eager loaded with the page
144 # content updated_on was eager loaded with the page
145 begin
145 begin
146 @updated_on = Time.zone ? Time.zone.parse(time.to_s) : Time.parse(time.to_s)
146 @updated_on = Time.zone ? Time.zone.parse(time.to_s) : Time.parse(time.to_s)
147 rescue
147 rescue
148 end
148 end
149 else
149 else
150 @updated_on = content && content.updated_on
150 @updated_on = content && content.updated_on
151 end
151 end
152 end
152 end
153 @updated_on
153 @updated_on
154 end
154 end
155
155
156 # Returns true if usr is allowed to edit the page, otherwise false
156 # Returns true if usr is allowed to edit the page, otherwise false
157 def editable_by?(usr)
157 def editable_by?(usr)
158 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
158 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
159 end
159 end
160
160
161 def attachments_deletable?(usr=User.current)
161 def attachments_deletable?(usr=User.current)
162 editable_by?(usr) && super(usr)
162 editable_by?(usr) && super(usr)
163 end
163 end
164
164
165 def parent_title
165 def parent_title
166 @parent_title || (self.parent && self.parent.pretty_title)
166 @parent_title || (self.parent && self.parent.pretty_title)
167 end
167 end
168
168
169 def parent_title=(t)
169 def parent_title=(t)
170 @parent_title = t
170 @parent_title = t
171 parent_page = t.blank? ? nil : self.wiki.find_page(t)
171 parent_page = t.blank? ? nil : self.wiki.find_page(t)
172 self.parent = parent_page
172 self.parent = parent_page
173 end
173 end
174
174
175 protected
175 protected
176
176
177 def validate_parent_title
177 def validate_parent_title
178 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
178 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
179 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
179 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
180 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
180 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
181 end
181 end
182 end
182 end
183
183
184 class WikiDiff < Redmine::Helpers::Diff
184 class WikiDiff < Redmine::Helpers::Diff
185 attr_reader :content_to, :content_from
185 attr_reader :content_to, :content_from
186
186
187 def initialize(content_to, content_from)
187 def initialize(content_to, content_from)
188 @content_to = content_to
188 @content_to = content_to
189 @content_from = content_from
189 @content_from = content_from
190 super(content_to.text, content_from.text)
190 super(content_to.text, content_from.text)
191 end
191 end
192 end
192 end
193
193
194 class WikiAnnotate
194 class WikiAnnotate
195 attr_reader :lines, :content
195 attr_reader :lines, :content
196
196
197 def initialize(content)
197 def initialize(content)
198 @content = content
198 @content = content
199 current = content
199 current = content
200 current_lines = current.text.split(/\r?\n/)
200 current_lines = current.text.split(/\r?\n/)
201 @lines = current_lines.collect {|t| [nil, nil, t]}
201 @lines = current_lines.collect {|t| [nil, nil, t]}
202 positions = []
202 positions = []
203 current_lines.size.times {|i| positions << i}
203 current_lines.size.times {|i| positions << i}
204 while (current.previous)
204 while (current.previous)
205 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
205 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
206 d.each_slice(3) do |s|
206 d.each_slice(3) do |s|
207 sign, line = s[0], s[1]
207 sign, line = s[0], s[1]
208 if sign == '+' && positions[line] && positions[line] != -1
208 if sign == '+' && positions[line] && positions[line] != -1
209 if @lines[positions[line]][0].nil?
209 if @lines[positions[line]][0].nil?
210 @lines[positions[line]][0] = current.version
210 @lines[positions[line]][0] = current.version
211 @lines[positions[line]][1] = current.author
211 @lines[positions[line]][1] = current.author
212 end
212 end
213 end
213 end
214 end
214 end
215 d.each_slice(3) do |s|
215 d.each_slice(3) do |s|
216 sign, line = s[0], s[1]
216 sign, line = s[0], s[1]
217 if sign == '-'
217 if sign == '-'
218 positions.insert(line, -1)
218 positions.insert(line, -1)
219 else
219 else
220 positions[line] = nil
220 positions[line] = nil
221 end
221 end
222 end
222 end
223 positions.compact!
223 positions.compact!
224 # Stop if every line is annotated
224 # Stop if every line is annotated
225 break unless @lines.detect { |line| line[0].nil? }
225 break unless @lines.detect { |line| line[0].nil? }
226 current = current.previous
226 current = current.previous
227 end
227 end
228 @lines.each { |line|
228 @lines.each { |line|
229 line[0] ||= current.version
229 line[0] ||= current.version
230 # if the last known version is > 1 (eg. history was cleared), we don't know the author
230 # if the last known version is > 1 (eg. history was cleared), we don't know the author
231 line[1] ||= current.author if current.version == 1
231 line[1] ||= current.author if current.version == 1
232 }
232 }
233 end
233 end
234 end
234 end
General Comments 0
You need to be logged in to leave comments. Login now