##// END OF EJS Templates
Rewrites named scopes with ARel queries....
Jean-Philippe Lang -
r10723:7222e4012db5
parent child
Show More
@@ -1,89 +1,90
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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_tree :dependent => :nullify
24 acts_as_tree :dependent => :nullify
25 acts_as_list :scope => '(project_id = #{project_id} AND parent_id #{parent_id ? "= #{parent_id}" : "IS NULL"})'
25 acts_as_list :scope => '(project_id = #{project_id} AND parent_id #{parent_id ? "= #{parent_id}" : "IS NULL"})'
26 acts_as_watchable
26 acts_as_watchable
27
27
28 validates_presence_of :name, :description
28 validates_presence_of :name, :description
29 validates_length_of :name, :maximum => 30
29 validates_length_of :name, :maximum => 30
30 validates_length_of :description, :maximum => 255
30 validates_length_of :description, :maximum => 255
31 validate :validate_board
31 validate :validate_board
32
32
33 scope :visible, lambda {|*args| { :include => :project,
33 scope :visible, lambda {|*args|
34 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
34 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
35 }
35
36
36 safe_attributes 'name', 'description', 'parent_id', 'move_to'
37 safe_attributes 'name', 'description', 'parent_id', 'move_to'
37
38
38 def visible?(user=User.current)
39 def visible?(user=User.current)
39 !user.nil? && user.allowed_to?(:view_messages, project)
40 !user.nil? && user.allowed_to?(:view_messages, project)
40 end
41 end
41
42
42 def reload(*args)
43 def reload(*args)
43 @valid_parents = nil
44 @valid_parents = nil
44 super
45 super
45 end
46 end
46
47
47 def to_s
48 def to_s
48 name
49 name
49 end
50 end
50
51
51 def valid_parents
52 def valid_parents
52 @valid_parents ||= project.boards - self_and_descendants
53 @valid_parents ||= project.boards - self_and_descendants
53 end
54 end
54
55
55 def reset_counters!
56 def reset_counters!
56 self.class.reset_counters!(id)
57 self.class.reset_counters!(id)
57 end
58 end
58
59
59 # Updates topics_count, messages_count and last_message_id attributes for +board_id+
60 # Updates topics_count, messages_count and last_message_id attributes for +board_id+
60 def self.reset_counters!(board_id)
61 def self.reset_counters!(board_id)
61 board_id = board_id.to_i
62 board_id = board_id.to_i
62 update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
63 update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
63 " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
64 " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
64 " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})",
65 " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})",
65 ["id = ?", board_id])
66 ["id = ?", board_id])
66 end
67 end
67
68
68 def self.board_tree(boards, parent_id=nil, level=0)
69 def self.board_tree(boards, parent_id=nil, level=0)
69 tree = []
70 tree = []
70 boards.select {|board| board.parent_id == parent_id}.sort_by(&:position).each do |board|
71 boards.select {|board| board.parent_id == parent_id}.sort_by(&:position).each do |board|
71 tree << [board, level]
72 tree << [board, level]
72 tree += board_tree(boards, board.id, level+1)
73 tree += board_tree(boards, board.id, level+1)
73 end
74 end
74 if block_given?
75 if block_given?
75 tree.each do |board, level|
76 tree.each do |board, level|
76 yield board, level
77 yield board, level
77 end
78 end
78 end
79 end
79 tree
80 tree
80 end
81 end
81
82
82 protected
83 protected
83
84
84 def validate_board
85 def validate_board
85 if parent_id && parent_id_changed?
86 if parent_id && parent_id_changed?
86 errors.add(:parent_id, :invalid) unless valid_parents.include?(parent)
87 errors.add(:parent_id, :invalid) unless valid_parents.include?(parent)
87 end
88 end
88 end
89 end
89 end
90 end
@@ -1,280 +1,280
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 :filechanges, :class_name => 'Change', :dependent => :delete_all
23 has_many :filechanges, :class_name => 'Change', :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 scope :visible,
52 scope :visible, lambda {|*args|
53 lambda {|*args| { :include => {:repository => :project},
53 includes(:repository => :project).where(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
56 after_create :scan_for_issues
56 after_create :scan_for_issues
57 before_create :before_create_cs
57 before_create :before_create_cs
58
58
59 def revision=(r)
59 def revision=(r)
60 write_attribute :revision, (r.nil? ? nil : r.to_s)
60 write_attribute :revision, (r.nil? ? nil : r.to_s)
61 end
61 end
62
62
63 # Returns the identifier of this changeset; depending on repository backends
63 # Returns the identifier of this changeset; depending on repository backends
64 def identifier
64 def identifier
65 if repository.class.respond_to? :changeset_identifier
65 if repository.class.respond_to? :changeset_identifier
66 repository.class.changeset_identifier self
66 repository.class.changeset_identifier self
67 else
67 else
68 revision.to_s
68 revision.to_s
69 end
69 end
70 end
70 end
71
71
72 def committed_on=(date)
72 def committed_on=(date)
73 self.commit_date = date
73 self.commit_date = date
74 super
74 super
75 end
75 end
76
76
77 # Returns the readable identifier
77 # Returns the readable identifier
78 def format_identifier
78 def format_identifier
79 if repository.class.respond_to? :format_changeset_identifier
79 if repository.class.respond_to? :format_changeset_identifier
80 repository.class.format_changeset_identifier self
80 repository.class.format_changeset_identifier self
81 else
81 else
82 identifier
82 identifier
83 end
83 end
84 end
84 end
85
85
86 def project
86 def project
87 repository.project
87 repository.project
88 end
88 end
89
89
90 def author
90 def author
91 user || committer.to_s.split('<').first
91 user || committer.to_s.split('<').first
92 end
92 end
93
93
94 def before_create_cs
94 def before_create_cs
95 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)
96 self.comments = self.class.normalize_comments(
96 self.comments = self.class.normalize_comments(
97 self.comments, repository.repo_log_encoding)
97 self.comments, repository.repo_log_encoding)
98 self.user = repository.find_committer_user(self.committer)
98 self.user = repository.find_committer_user(self.committer)
99 end
99 end
100
100
101 def scan_for_issues
101 def scan_for_issues
102 scan_comment_for_issue_ids
102 scan_comment_for_issue_ids
103 end
103 end
104
104
105 TIMELOG_RE = /
105 TIMELOG_RE = /
106 (
106 (
107 ((\d+)(h|hours?))((\d+)(m|min)?)?
107 ((\d+)(h|hours?))((\d+)(m|min)?)?
108 |
108 |
109 ((\d+)(h|hours?|m|min))
109 ((\d+)(h|hours?|m|min))
110 |
110 |
111 (\d+):(\d+)
111 (\d+):(\d+)
112 |
112 |
113 (\d+([\.,]\d+)?)h?
113 (\d+([\.,]\d+)?)h?
114 )
114 )
115 /x
115 /x
116
116
117 def scan_comment_for_issue_ids
117 def scan_comment_for_issue_ids
118 return if comments.blank?
118 return if comments.blank?
119 # keywords used to reference issues
119 # keywords used to reference issues
120 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
120 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
121 ref_keywords_any = ref_keywords.delete('*')
121 ref_keywords_any = ref_keywords.delete('*')
122 # keywords used to fix issues
122 # keywords used to fix issues
123 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
123 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
124
124
125 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("|")
126
126
127 referenced_issues = []
127 referenced_issues = []
128
128
129 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|
130 action, refs = match[2], match[3]
130 action, refs = match[2], match[3]
131 next unless action.present? || ref_keywords_any
131 next unless action.present? || ref_keywords_any
132
132
133 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
133 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
134 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]
135 if issue
135 if issue
136 referenced_issues << issue
136 referenced_issues << issue
137 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
137 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
138 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
138 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
139 end
139 end
140 end
140 end
141 end
141 end
142
142
143 referenced_issues.uniq!
143 referenced_issues.uniq!
144 self.issues = referenced_issues unless referenced_issues.empty?
144 self.issues = referenced_issues unless referenced_issues.empty?
145 end
145 end
146
146
147 def short_comments
147 def short_comments
148 @short_comments || split_comments.first
148 @short_comments || split_comments.first
149 end
149 end
150
150
151 def long_comments
151 def long_comments
152 @long_comments || split_comments.last
152 @long_comments || split_comments.last
153 end
153 end
154
154
155 def text_tag(ref_project=nil)
155 def text_tag(ref_project=nil)
156 tag = if scmid?
156 tag = if scmid?
157 "commit:#{scmid}"
157 "commit:#{scmid}"
158 else
158 else
159 "r#{revision}"
159 "r#{revision}"
160 end
160 end
161 if repository && repository.identifier.present?
161 if repository && repository.identifier.present?
162 tag = "#{repository.identifier}|#{tag}"
162 tag = "#{repository.identifier}|#{tag}"
163 end
163 end
164 if ref_project && project && ref_project != project
164 if ref_project && project && ref_project != project
165 tag = "#{project.identifier}:#{tag}"
165 tag = "#{project.identifier}:#{tag}"
166 end
166 end
167 tag
167 tag
168 end
168 end
169
169
170 # 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
171 def title
171 def title
172 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
172 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
173 comm = short_comments.blank? ? '' : (': ' + short_comments)
173 comm = short_comments.blank? ? '' : (': ' + short_comments)
174 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
174 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
175 end
175 end
176
176
177 # Returns the previous changeset
177 # Returns the previous changeset
178 def previous
178 def previous
179 @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
179 @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
180 end
180 end
181
181
182 # Returns the next changeset
182 # Returns the next changeset
183 def next
183 def next
184 @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
184 @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
185 end
185 end
186
186
187 # Creates a new Change from it's common parameters
187 # Creates a new Change from it's common parameters
188 def create_change(change)
188 def create_change(change)
189 Change.create(:changeset => self,
189 Change.create(:changeset => self,
190 :action => change[:action],
190 :action => change[:action],
191 :path => change[:path],
191 :path => change[:path],
192 :from_path => change[:from_path],
192 :from_path => change[:from_path],
193 :from_revision => change[:from_revision])
193 :from_revision => change[:from_revision])
194 end
194 end
195
195
196 # Finds an issue that can be referenced by the commit message
196 # Finds an issue that can be referenced by the commit message
197 def find_referenced_issue_by_id(id)
197 def find_referenced_issue_by_id(id)
198 return nil if id.blank?
198 return nil if id.blank?
199 issue = Issue.find_by_id(id.to_i, :include => :project)
199 issue = Issue.find_by_id(id.to_i, :include => :project)
200 if Setting.commit_cross_project_ref?
200 if Setting.commit_cross_project_ref?
201 # all issues can be referenced/fixed
201 # all issues can be referenced/fixed
202 elsif issue
202 elsif issue
203 # issue that belong to the repository project, a subproject or a parent project only
203 # issue that belong to the repository project, a subproject or a parent project only
204 unless issue.project &&
204 unless issue.project &&
205 (project == issue.project || project.is_ancestor_of?(issue.project) ||
205 (project == issue.project || project.is_ancestor_of?(issue.project) ||
206 project.is_descendant_of?(issue.project))
206 project.is_descendant_of?(issue.project))
207 issue = nil
207 issue = nil
208 end
208 end
209 end
209 end
210 issue
210 issue
211 end
211 end
212
212
213 private
213 private
214
214
215 def fix_issue(issue)
215 def fix_issue(issue)
216 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
216 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
217 if status.nil?
217 if status.nil?
218 logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
218 logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
219 return issue
219 return issue
220 end
220 end
221
221
222 # the issue may have been updated by the closure of another one (eg. duplicate)
222 # the issue may have been updated by the closure of another one (eg. duplicate)
223 issue.reload
223 issue.reload
224 # don't change the status is the issue is closed
224 # don't change the status is the issue is closed
225 return if issue.status && issue.status.is_closed?
225 return if issue.status && issue.status.is_closed?
226
226
227 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project)))
227 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project)))
228 issue.status = status
228 issue.status = status
229 unless Setting.commit_fix_done_ratio.blank?
229 unless Setting.commit_fix_done_ratio.blank?
230 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
230 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
231 end
231 end
232 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
232 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
233 { :changeset => self, :issue => issue })
233 { :changeset => self, :issue => issue })
234 unless issue.save
234 unless issue.save
235 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
235 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
236 end
236 end
237 issue
237 issue
238 end
238 end
239
239
240 def log_time(issue, hours)
240 def log_time(issue, hours)
241 time_entry = TimeEntry.new(
241 time_entry = TimeEntry.new(
242 :user => user,
242 :user => user,
243 :hours => hours,
243 :hours => hours,
244 :issue => issue,
244 :issue => issue,
245 :spent_on => commit_date,
245 :spent_on => commit_date,
246 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
246 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
247 :locale => Setting.default_language)
247 :locale => Setting.default_language)
248 )
248 )
249 time_entry.activity = log_time_activity unless log_time_activity.nil?
249 time_entry.activity = log_time_activity unless log_time_activity.nil?
250
250
251 unless time_entry.save
251 unless time_entry.save
252 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
252 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
253 end
253 end
254 time_entry
254 time_entry
255 end
255 end
256
256
257 def log_time_activity
257 def log_time_activity
258 if Setting.commit_logtime_activity_id.to_i > 0
258 if Setting.commit_logtime_activity_id.to_i > 0
259 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
259 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
260 end
260 end
261 end
261 end
262
262
263 def split_comments
263 def split_comments
264 comments =~ /\A(.+?)\r?\n(.*)$/m
264 comments =~ /\A(.+?)\r?\n(.*)$/m
265 @short_comments = $1 || comments
265 @short_comments = $1 || comments
266 @long_comments = $2.to_s.strip
266 @long_comments = $2.to_s.strip
267 return @short_comments, @long_comments
267 return @short_comments, @long_comments
268 end
268 end
269
269
270 public
270 public
271
271
272 # Strips and reencodes a commit log before insertion into the database
272 # Strips and reencodes a commit log before insertion into the database
273 def self.normalize_comments(str, encoding)
273 def self.normalize_comments(str, encoding)
274 Changeset.to_utf8(str.to_s.strip, encoding)
274 Changeset.to_utf8(str.to_s.strip, encoding)
275 end
275 end
276
276
277 def self.to_utf8(str, encoding)
277 def self.to_utf8(str, encoding)
278 Redmine::CodesetUtil.to_utf8(str, encoding)
278 Redmine::CodesetUtil.to_utf8(str, encoding)
279 end
279 end
280 end
280 end
@@ -1,56 +1,57
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
26 :author => Proc.new {|o| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
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 scope :visible, lambda {|*args| { :include => :project,
33 scope :visible, lambda {|*args|
34 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
34 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_documents, *args))
35 }
35
36
36 safe_attributes 'category_id', 'title', 'description'
37 safe_attributes 'category_id', 'title', 'description'
37
38
38 def visible?(user=User.current)
39 def visible?(user=User.current)
39 !user.nil? && user.allowed_to?(:view_documents, project)
40 !user.nil? && user.allowed_to?(:view_documents, project)
40 end
41 end
41
42
42 def initialize(attributes=nil, *args)
43 def initialize(attributes=nil, *args)
43 super
44 super
44 if new_record?
45 if new_record?
45 self.category ||= DocumentCategory.default
46 self.category ||= DocumentCategory.default
46 end
47 end
47 end
48 end
48
49
49 def updated_on
50 def updated_on
50 unless @updated_on
51 unless @updated_on
51 a = attachments.last
52 a = attachments.last
52 @updated_on = (a && a.created_on) || created_on
53 @updated_on = (a && a.created_on) || created_on
53 end
54 end
54 @updated_on
55 @updated_on
55 end
56 end
56 end
57 end
@@ -1,1395 +1,1396
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 include Redmine::Utils::DateCalculation
20 include Redmine::Utils::DateCalculation
21
21
22 belongs_to :project
22 belongs_to :project
23 belongs_to :tracker
23 belongs_to :tracker
24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
30
30
31 has_many :journals, :as => :journalized, :dependent => :destroy
31 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :visible_journals,
32 has_many :visible_journals,
33 :class_name => 'Journal',
33 :class_name => 'Journal',
34 :as => :journalized,
34 :as => :journalized,
35 :conditions => Proc.new {
35 :conditions => Proc.new {
36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 },
37 },
38 :readonly => true
38 :readonly => true
39
39
40 has_many :time_entries, :dependent => :delete_all
40 has_many :time_entries, :dependent => :delete_all
41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
42
42
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45
45
46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
48 acts_as_customizable
48 acts_as_customizable
49 acts_as_watchable
49 acts_as_watchable
50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
51 :include => [:project, :visible_journals],
51 :include => [:project, :visible_journals],
52 # sort by id so that limited eager loading doesn't break with postgresql
52 # sort by id so that limited eager loading doesn't break with postgresql
53 :order_column => "#{table_name}.id"
53 :order_column => "#{table_name}.id"
54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
57
57
58 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
58 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
59 :author_key => :author_id
59 :author_key => :author_id
60
60
61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
62
62
63 attr_reader :current_journal
63 attr_reader :current_journal
64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65
65
66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67
67
68 validates_length_of :subject, :maximum => 255
68 validates_length_of :subject, :maximum => 255
69 validates_inclusion_of :done_ratio, :in => 0..100
69 validates_inclusion_of :done_ratio, :in => 0..100
70 validates_numericality_of :estimated_hours, :allow_nil => true
70 validates_numericality_of :estimated_hours, :allow_nil => true
71 validate :validate_issue, :validate_required_fields
71 validate :validate_issue, :validate_required_fields
72
72
73 scope :visible,
73 scope :visible, lambda {|*args|
74 lambda {|*args| { :include => :project,
74 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
75 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
75 }
76
76
77 scope :open, lambda {|*args|
77 scope :open, lambda {|*args|
78 is_closed = args.size > 0 ? !args.first : false
78 is_closed = args.size > 0 ? !args.first : false
79 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
79 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
80 }
80 }
81
81
82 scope :recently_updated, lambda { { :order => "#{Issue.table_name}.updated_on DESC" } }
82 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
83 scope :on_active_project, lambda { { :include => [:status, :project, :tracker],
83 scope :on_active_project, lambda {
84 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"] } }
84 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
85 }
85
86
86 before_create :default_assign
87 before_create :default_assign
87 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
88 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
88 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
89 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 # Should be after_create but would be called before previous after_save callbacks
91 # Should be after_create but would be called before previous after_save callbacks
91 after_save :after_create_from_copy
92 after_save :after_create_from_copy
92 after_destroy :update_parent_attributes
93 after_destroy :update_parent_attributes
93
94
94 # Returns a SQL conditions string used to find all issues visible by the specified user
95 # Returns a SQL conditions string used to find all issues visible by the specified user
95 def self.visible_condition(user, options={})
96 def self.visible_condition(user, options={})
96 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
97 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
97 if user.logged?
98 if user.logged?
98 case role.issues_visibility
99 case role.issues_visibility
99 when 'all'
100 when 'all'
100 nil
101 nil
101 when 'default'
102 when 'default'
102 user_ids = [user.id] + user.groups.map(&:id)
103 user_ids = [user.id] + user.groups.map(&:id)
103 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
104 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
104 when 'own'
105 when 'own'
105 user_ids = [user.id] + user.groups.map(&:id)
106 user_ids = [user.id] + user.groups.map(&:id)
106 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
107 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
107 else
108 else
108 '1=0'
109 '1=0'
109 end
110 end
110 else
111 else
111 "(#{table_name}.is_private = #{connection.quoted_false})"
112 "(#{table_name}.is_private = #{connection.quoted_false})"
112 end
113 end
113 end
114 end
114 end
115 end
115
116
116 # Returns true if usr or current user is allowed to view the issue
117 # Returns true if usr or current user is allowed to view the issue
117 def visible?(usr=nil)
118 def visible?(usr=nil)
118 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
119 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
119 if user.logged?
120 if user.logged?
120 case role.issues_visibility
121 case role.issues_visibility
121 when 'all'
122 when 'all'
122 true
123 true
123 when 'default'
124 when 'default'
124 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
125 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
125 when 'own'
126 when 'own'
126 self.author == user || user.is_or_belongs_to?(assigned_to)
127 self.author == user || user.is_or_belongs_to?(assigned_to)
127 else
128 else
128 false
129 false
129 end
130 end
130 else
131 else
131 !self.is_private?
132 !self.is_private?
132 end
133 end
133 end
134 end
134 end
135 end
135
136
136 def initialize(attributes=nil, *args)
137 def initialize(attributes=nil, *args)
137 super
138 super
138 if new_record?
139 if new_record?
139 # set default values for new records only
140 # set default values for new records only
140 self.status ||= IssueStatus.default
141 self.status ||= IssueStatus.default
141 self.priority ||= IssuePriority.default
142 self.priority ||= IssuePriority.default
142 self.watcher_user_ids = []
143 self.watcher_user_ids = []
143 end
144 end
144 end
145 end
145
146
146 # AR#Persistence#destroy would raise and RecordNotFound exception
147 # AR#Persistence#destroy would raise and RecordNotFound exception
147 # if the issue was already deleted or updated (non matching lock_version).
148 # if the issue was already deleted or updated (non matching lock_version).
148 # This is a problem when bulk deleting issues or deleting a project
149 # This is a problem when bulk deleting issues or deleting a project
149 # (because an issue may already be deleted if its parent was deleted
150 # (because an issue may already be deleted if its parent was deleted
150 # first).
151 # first).
151 # The issue is reloaded by the nested_set before being deleted so
152 # The issue is reloaded by the nested_set before being deleted so
152 # the lock_version condition should not be an issue but we handle it.
153 # the lock_version condition should not be an issue but we handle it.
153 def destroy
154 def destroy
154 super
155 super
155 rescue ActiveRecord::RecordNotFound
156 rescue ActiveRecord::RecordNotFound
156 # Stale or already deleted
157 # Stale or already deleted
157 begin
158 begin
158 reload
159 reload
159 rescue ActiveRecord::RecordNotFound
160 rescue ActiveRecord::RecordNotFound
160 # The issue was actually already deleted
161 # The issue was actually already deleted
161 @destroyed = true
162 @destroyed = true
162 return freeze
163 return freeze
163 end
164 end
164 # The issue was stale, retry to destroy
165 # The issue was stale, retry to destroy
165 super
166 super
166 end
167 end
167
168
168 def reload(*args)
169 def reload(*args)
169 @workflow_rule_by_attribute = nil
170 @workflow_rule_by_attribute = nil
170 @assignable_versions = nil
171 @assignable_versions = nil
171 super
172 super
172 end
173 end
173
174
174 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
175 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
175 def available_custom_fields
176 def available_custom_fields
176 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
177 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
177 end
178 end
178
179
179 # Copies attributes from another issue, arg can be an id or an Issue
180 # Copies attributes from another issue, arg can be an id or an Issue
180 def copy_from(arg, options={})
181 def copy_from(arg, options={})
181 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
182 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
182 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
183 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
183 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
184 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
184 self.status = issue.status
185 self.status = issue.status
185 self.author = User.current
186 self.author = User.current
186 unless options[:attachments] == false
187 unless options[:attachments] == false
187 self.attachments = issue.attachments.map do |attachement|
188 self.attachments = issue.attachments.map do |attachement|
188 attachement.copy(:container => self)
189 attachement.copy(:container => self)
189 end
190 end
190 end
191 end
191 @copied_from = issue
192 @copied_from = issue
192 @copy_options = options
193 @copy_options = options
193 self
194 self
194 end
195 end
195
196
196 # Returns an unsaved copy of the issue
197 # Returns an unsaved copy of the issue
197 def copy(attributes=nil, copy_options={})
198 def copy(attributes=nil, copy_options={})
198 copy = self.class.new.copy_from(self, copy_options)
199 copy = self.class.new.copy_from(self, copy_options)
199 copy.attributes = attributes if attributes
200 copy.attributes = attributes if attributes
200 copy
201 copy
201 end
202 end
202
203
203 # Returns true if the issue is a copy
204 # Returns true if the issue is a copy
204 def copy?
205 def copy?
205 @copied_from.present?
206 @copied_from.present?
206 end
207 end
207
208
208 # Moves/copies an issue to a new project and tracker
209 # Moves/copies an issue to a new project and tracker
209 # Returns the moved/copied issue on success, false on failure
210 # Returns the moved/copied issue on success, false on failure
210 def move_to_project(new_project, new_tracker=nil, options={})
211 def move_to_project(new_project, new_tracker=nil, options={})
211 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
212 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
212
213
213 if options[:copy]
214 if options[:copy]
214 issue = self.copy
215 issue = self.copy
215 else
216 else
216 issue = self
217 issue = self
217 end
218 end
218
219
219 issue.init_journal(User.current, options[:notes])
220 issue.init_journal(User.current, options[:notes])
220
221
221 # Preserve previous behaviour
222 # Preserve previous behaviour
222 # #move_to_project doesn't change tracker automatically
223 # #move_to_project doesn't change tracker automatically
223 issue.send :project=, new_project, true
224 issue.send :project=, new_project, true
224 if new_tracker
225 if new_tracker
225 issue.tracker = new_tracker
226 issue.tracker = new_tracker
226 end
227 end
227 # Allow bulk setting of attributes on the issue
228 # Allow bulk setting of attributes on the issue
228 if options[:attributes]
229 if options[:attributes]
229 issue.attributes = options[:attributes]
230 issue.attributes = options[:attributes]
230 end
231 end
231
232
232 issue.save ? issue : false
233 issue.save ? issue : false
233 end
234 end
234
235
235 def status_id=(sid)
236 def status_id=(sid)
236 self.status = nil
237 self.status = nil
237 result = write_attribute(:status_id, sid)
238 result = write_attribute(:status_id, sid)
238 @workflow_rule_by_attribute = nil
239 @workflow_rule_by_attribute = nil
239 result
240 result
240 end
241 end
241
242
242 def priority_id=(pid)
243 def priority_id=(pid)
243 self.priority = nil
244 self.priority = nil
244 write_attribute(:priority_id, pid)
245 write_attribute(:priority_id, pid)
245 end
246 end
246
247
247 def category_id=(cid)
248 def category_id=(cid)
248 self.category = nil
249 self.category = nil
249 write_attribute(:category_id, cid)
250 write_attribute(:category_id, cid)
250 end
251 end
251
252
252 def fixed_version_id=(vid)
253 def fixed_version_id=(vid)
253 self.fixed_version = nil
254 self.fixed_version = nil
254 write_attribute(:fixed_version_id, vid)
255 write_attribute(:fixed_version_id, vid)
255 end
256 end
256
257
257 def tracker_id=(tid)
258 def tracker_id=(tid)
258 self.tracker = nil
259 self.tracker = nil
259 result = write_attribute(:tracker_id, tid)
260 result = write_attribute(:tracker_id, tid)
260 @custom_field_values = nil
261 @custom_field_values = nil
261 @workflow_rule_by_attribute = nil
262 @workflow_rule_by_attribute = nil
262 result
263 result
263 end
264 end
264
265
265 def project_id=(project_id)
266 def project_id=(project_id)
266 if project_id.to_s != self.project_id.to_s
267 if project_id.to_s != self.project_id.to_s
267 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
268 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
268 end
269 end
269 end
270 end
270
271
271 def project=(project, keep_tracker=false)
272 def project=(project, keep_tracker=false)
272 project_was = self.project
273 project_was = self.project
273 write_attribute(:project_id, project ? project.id : nil)
274 write_attribute(:project_id, project ? project.id : nil)
274 association_instance_set('project', project)
275 association_instance_set('project', project)
275 if project_was && project && project_was != project
276 if project_was && project && project_was != project
276 @assignable_versions = nil
277 @assignable_versions = nil
277
278
278 unless keep_tracker || project.trackers.include?(tracker)
279 unless keep_tracker || project.trackers.include?(tracker)
279 self.tracker = project.trackers.first
280 self.tracker = project.trackers.first
280 end
281 end
281 # Reassign to the category with same name if any
282 # Reassign to the category with same name if any
282 if category
283 if category
283 self.category = project.issue_categories.find_by_name(category.name)
284 self.category = project.issue_categories.find_by_name(category.name)
284 end
285 end
285 # Keep the fixed_version if it's still valid in the new_project
286 # Keep the fixed_version if it's still valid in the new_project
286 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
287 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
287 self.fixed_version = nil
288 self.fixed_version = nil
288 end
289 end
289 # Clear the parent task if it's no longer valid
290 # Clear the parent task if it's no longer valid
290 unless valid_parent_project?
291 unless valid_parent_project?
291 self.parent_issue_id = nil
292 self.parent_issue_id = nil
292 end
293 end
293 @custom_field_values = nil
294 @custom_field_values = nil
294 end
295 end
295 end
296 end
296
297
297 def description=(arg)
298 def description=(arg)
298 if arg.is_a?(String)
299 if arg.is_a?(String)
299 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
300 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
300 end
301 end
301 write_attribute(:description, arg)
302 write_attribute(:description, arg)
302 end
303 end
303
304
304 # Overrides assign_attributes so that project and tracker get assigned first
305 # Overrides assign_attributes so that project and tracker get assigned first
305 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
306 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
306 return if new_attributes.nil?
307 return if new_attributes.nil?
307 attrs = new_attributes.dup
308 attrs = new_attributes.dup
308 attrs.stringify_keys!
309 attrs.stringify_keys!
309
310
310 %w(project project_id tracker tracker_id).each do |attr|
311 %w(project project_id tracker tracker_id).each do |attr|
311 if attrs.has_key?(attr)
312 if attrs.has_key?(attr)
312 send "#{attr}=", attrs.delete(attr)
313 send "#{attr}=", attrs.delete(attr)
313 end
314 end
314 end
315 end
315 send :assign_attributes_without_project_and_tracker_first, attrs, *args
316 send :assign_attributes_without_project_and_tracker_first, attrs, *args
316 end
317 end
317 # Do not redefine alias chain on reload (see #4838)
318 # Do not redefine alias chain on reload (see #4838)
318 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
319 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
319
320
320 def estimated_hours=(h)
321 def estimated_hours=(h)
321 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
322 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
322 end
323 end
323
324
324 safe_attributes 'project_id',
325 safe_attributes 'project_id',
325 :if => lambda {|issue, user|
326 :if => lambda {|issue, user|
326 if issue.new_record?
327 if issue.new_record?
327 issue.copy?
328 issue.copy?
328 elsif user.allowed_to?(:move_issues, issue.project)
329 elsif user.allowed_to?(:move_issues, issue.project)
329 projects = Issue.allowed_target_projects_on_move(user)
330 projects = Issue.allowed_target_projects_on_move(user)
330 projects.include?(issue.project) && projects.size > 1
331 projects.include?(issue.project) && projects.size > 1
331 end
332 end
332 }
333 }
333
334
334 safe_attributes 'tracker_id',
335 safe_attributes 'tracker_id',
335 'status_id',
336 'status_id',
336 'category_id',
337 'category_id',
337 'assigned_to_id',
338 'assigned_to_id',
338 'priority_id',
339 'priority_id',
339 'fixed_version_id',
340 'fixed_version_id',
340 'subject',
341 'subject',
341 'description',
342 'description',
342 'start_date',
343 'start_date',
343 'due_date',
344 'due_date',
344 'done_ratio',
345 'done_ratio',
345 'estimated_hours',
346 'estimated_hours',
346 'custom_field_values',
347 'custom_field_values',
347 'custom_fields',
348 'custom_fields',
348 'lock_version',
349 'lock_version',
349 'notes',
350 'notes',
350 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
351 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
351
352
352 safe_attributes 'status_id',
353 safe_attributes 'status_id',
353 'assigned_to_id',
354 'assigned_to_id',
354 'fixed_version_id',
355 'fixed_version_id',
355 'done_ratio',
356 'done_ratio',
356 'lock_version',
357 'lock_version',
357 'notes',
358 'notes',
358 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
359 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
359
360
360 safe_attributes 'notes',
361 safe_attributes 'notes',
361 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
362 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
362
363
363 safe_attributes 'private_notes',
364 safe_attributes 'private_notes',
364 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
365 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
365
366
366 safe_attributes 'watcher_user_ids',
367 safe_attributes 'watcher_user_ids',
367 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
368 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
368
369
369 safe_attributes 'is_private',
370 safe_attributes 'is_private',
370 :if => lambda {|issue, user|
371 :if => lambda {|issue, user|
371 user.allowed_to?(:set_issues_private, issue.project) ||
372 user.allowed_to?(:set_issues_private, issue.project) ||
372 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
373 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
373 }
374 }
374
375
375 safe_attributes 'parent_issue_id',
376 safe_attributes 'parent_issue_id',
376 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
377 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
377 user.allowed_to?(:manage_subtasks, issue.project)}
378 user.allowed_to?(:manage_subtasks, issue.project)}
378
379
379 def safe_attribute_names(user=nil)
380 def safe_attribute_names(user=nil)
380 names = super
381 names = super
381 names -= disabled_core_fields
382 names -= disabled_core_fields
382 names -= read_only_attribute_names(user)
383 names -= read_only_attribute_names(user)
383 names
384 names
384 end
385 end
385
386
386 # Safely sets attributes
387 # Safely sets attributes
387 # Should be called from controllers instead of #attributes=
388 # Should be called from controllers instead of #attributes=
388 # attr_accessible is too rough because we still want things like
389 # attr_accessible is too rough because we still want things like
389 # Issue.new(:project => foo) to work
390 # Issue.new(:project => foo) to work
390 def safe_attributes=(attrs, user=User.current)
391 def safe_attributes=(attrs, user=User.current)
391 return unless attrs.is_a?(Hash)
392 return unless attrs.is_a?(Hash)
392
393
393 attrs = attrs.dup
394 attrs = attrs.dup
394
395
395 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
396 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
396 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
397 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
397 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
398 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
398 self.project_id = p
399 self.project_id = p
399 end
400 end
400 end
401 end
401
402
402 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
403 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
403 self.tracker_id = t
404 self.tracker_id = t
404 end
405 end
405
406
406 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
407 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
407 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
408 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
408 self.status_id = s
409 self.status_id = s
409 end
410 end
410 end
411 end
411
412
412 attrs = delete_unsafe_attributes(attrs, user)
413 attrs = delete_unsafe_attributes(attrs, user)
413 return if attrs.empty?
414 return if attrs.empty?
414
415
415 unless leaf?
416 unless leaf?
416 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
417 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
417 end
418 end
418
419
419 if attrs['parent_issue_id'].present?
420 if attrs['parent_issue_id'].present?
420 s = attrs['parent_issue_id'].to_s
421 s = attrs['parent_issue_id'].to_s
421 unless (m = s.match(%r{\A#?(\d+)\z})) && Issue.visible(user).exists?(m[1])
422 unless (m = s.match(%r{\A#?(\d+)\z})) && Issue.visible(user).exists?(m[1])
422 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
423 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
423 end
424 end
424 end
425 end
425
426
426 if attrs['custom_field_values'].present?
427 if attrs['custom_field_values'].present?
427 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
428 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
428 end
429 end
429
430
430 if attrs['custom_fields'].present?
431 if attrs['custom_fields'].present?
431 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
432 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
432 end
433 end
433
434
434 # mass-assignment security bypass
435 # mass-assignment security bypass
435 assign_attributes attrs, :without_protection => true
436 assign_attributes attrs, :without_protection => true
436 end
437 end
437
438
438 def disabled_core_fields
439 def disabled_core_fields
439 tracker ? tracker.disabled_core_fields : []
440 tracker ? tracker.disabled_core_fields : []
440 end
441 end
441
442
442 # Returns the custom_field_values that can be edited by the given user
443 # Returns the custom_field_values that can be edited by the given user
443 def editable_custom_field_values(user=nil)
444 def editable_custom_field_values(user=nil)
444 custom_field_values.reject do |value|
445 custom_field_values.reject do |value|
445 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
446 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
446 end
447 end
447 end
448 end
448
449
449 # Returns the names of attributes that are read-only for user or the current user
450 # Returns the names of attributes that are read-only for user or the current user
450 # For users with multiple roles, the read-only fields are the intersection of
451 # For users with multiple roles, the read-only fields are the intersection of
451 # read-only fields of each role
452 # read-only fields of each role
452 # The result is an array of strings where sustom fields are represented with their ids
453 # The result is an array of strings where sustom fields are represented with their ids
453 #
454 #
454 # Examples:
455 # Examples:
455 # issue.read_only_attribute_names # => ['due_date', '2']
456 # issue.read_only_attribute_names # => ['due_date', '2']
456 # issue.read_only_attribute_names(user) # => []
457 # issue.read_only_attribute_names(user) # => []
457 def read_only_attribute_names(user=nil)
458 def read_only_attribute_names(user=nil)
458 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
459 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
459 end
460 end
460
461
461 # Returns the names of required attributes for user or the current user
462 # Returns the names of required attributes for user or the current user
462 # For users with multiple roles, the required fields are the intersection of
463 # For users with multiple roles, the required fields are the intersection of
463 # required fields of each role
464 # required fields of each role
464 # The result is an array of strings where sustom fields are represented with their ids
465 # The result is an array of strings where sustom fields are represented with their ids
465 #
466 #
466 # Examples:
467 # Examples:
467 # issue.required_attribute_names # => ['due_date', '2']
468 # issue.required_attribute_names # => ['due_date', '2']
468 # issue.required_attribute_names(user) # => []
469 # issue.required_attribute_names(user) # => []
469 def required_attribute_names(user=nil)
470 def required_attribute_names(user=nil)
470 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
471 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
471 end
472 end
472
473
473 # Returns true if the attribute is required for user
474 # Returns true if the attribute is required for user
474 def required_attribute?(name, user=nil)
475 def required_attribute?(name, user=nil)
475 required_attribute_names(user).include?(name.to_s)
476 required_attribute_names(user).include?(name.to_s)
476 end
477 end
477
478
478 # Returns a hash of the workflow rule by attribute for the given user
479 # Returns a hash of the workflow rule by attribute for the given user
479 #
480 #
480 # Examples:
481 # Examples:
481 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
482 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
482 def workflow_rule_by_attribute(user=nil)
483 def workflow_rule_by_attribute(user=nil)
483 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
484 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
484
485
485 user_real = user || User.current
486 user_real = user || User.current
486 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
487 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
487 return {} if roles.empty?
488 return {} if roles.empty?
488
489
489 result = {}
490 result = {}
490 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
491 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
491 if workflow_permissions.any?
492 if workflow_permissions.any?
492 workflow_rules = workflow_permissions.inject({}) do |h, wp|
493 workflow_rules = workflow_permissions.inject({}) do |h, wp|
493 h[wp.field_name] ||= []
494 h[wp.field_name] ||= []
494 h[wp.field_name] << wp.rule
495 h[wp.field_name] << wp.rule
495 h
496 h
496 end
497 end
497 workflow_rules.each do |attr, rules|
498 workflow_rules.each do |attr, rules|
498 next if rules.size < roles.size
499 next if rules.size < roles.size
499 uniq_rules = rules.uniq
500 uniq_rules = rules.uniq
500 if uniq_rules.size == 1
501 if uniq_rules.size == 1
501 result[attr] = uniq_rules.first
502 result[attr] = uniq_rules.first
502 else
503 else
503 result[attr] = 'required'
504 result[attr] = 'required'
504 end
505 end
505 end
506 end
506 end
507 end
507 @workflow_rule_by_attribute = result if user.nil?
508 @workflow_rule_by_attribute = result if user.nil?
508 result
509 result
509 end
510 end
510 private :workflow_rule_by_attribute
511 private :workflow_rule_by_attribute
511
512
512 def done_ratio
513 def done_ratio
513 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
514 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
514 status.default_done_ratio
515 status.default_done_ratio
515 else
516 else
516 read_attribute(:done_ratio)
517 read_attribute(:done_ratio)
517 end
518 end
518 end
519 end
519
520
520 def self.use_status_for_done_ratio?
521 def self.use_status_for_done_ratio?
521 Setting.issue_done_ratio == 'issue_status'
522 Setting.issue_done_ratio == 'issue_status'
522 end
523 end
523
524
524 def self.use_field_for_done_ratio?
525 def self.use_field_for_done_ratio?
525 Setting.issue_done_ratio == 'issue_field'
526 Setting.issue_done_ratio == 'issue_field'
526 end
527 end
527
528
528 def validate_issue
529 def validate_issue
529 if due_date.nil? && @attributes['due_date'].present?
530 if due_date.nil? && @attributes['due_date'].present?
530 errors.add :due_date, :not_a_date
531 errors.add :due_date, :not_a_date
531 end
532 end
532
533
533 if start_date.nil? && @attributes['start_date'].present?
534 if start_date.nil? && @attributes['start_date'].present?
534 errors.add :start_date, :not_a_date
535 errors.add :start_date, :not_a_date
535 end
536 end
536
537
537 if due_date && start_date && due_date < start_date
538 if due_date && start_date && due_date < start_date
538 errors.add :due_date, :greater_than_start_date
539 errors.add :due_date, :greater_than_start_date
539 end
540 end
540
541
541 if start_date && soonest_start && start_date < soonest_start
542 if start_date && soonest_start && start_date < soonest_start
542 errors.add :start_date, :invalid
543 errors.add :start_date, :invalid
543 end
544 end
544
545
545 if fixed_version
546 if fixed_version
546 if !assignable_versions.include?(fixed_version)
547 if !assignable_versions.include?(fixed_version)
547 errors.add :fixed_version_id, :inclusion
548 errors.add :fixed_version_id, :inclusion
548 elsif reopened? && fixed_version.closed?
549 elsif reopened? && fixed_version.closed?
549 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
550 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
550 end
551 end
551 end
552 end
552
553
553 # Checks that the issue can not be added/moved to a disabled tracker
554 # Checks that the issue can not be added/moved to a disabled tracker
554 if project && (tracker_id_changed? || project_id_changed?)
555 if project && (tracker_id_changed? || project_id_changed?)
555 unless project.trackers.include?(tracker)
556 unless project.trackers.include?(tracker)
556 errors.add :tracker_id, :inclusion
557 errors.add :tracker_id, :inclusion
557 end
558 end
558 end
559 end
559
560
560 # Checks parent issue assignment
561 # Checks parent issue assignment
561 if @invalid_parent_issue_id.present?
562 if @invalid_parent_issue_id.present?
562 errors.add :parent_issue_id, :invalid
563 errors.add :parent_issue_id, :invalid
563 elsif @parent_issue
564 elsif @parent_issue
564 if !valid_parent_project?(@parent_issue)
565 if !valid_parent_project?(@parent_issue)
565 errors.add :parent_issue_id, :invalid
566 errors.add :parent_issue_id, :invalid
566 elsif !new_record?
567 elsif !new_record?
567 # moving an existing issue
568 # moving an existing issue
568 if @parent_issue.root_id != root_id
569 if @parent_issue.root_id != root_id
569 # we can always move to another tree
570 # we can always move to another tree
570 elsif move_possible?(@parent_issue)
571 elsif move_possible?(@parent_issue)
571 # move accepted inside tree
572 # move accepted inside tree
572 else
573 else
573 errors.add :parent_issue_id, :invalid
574 errors.add :parent_issue_id, :invalid
574 end
575 end
575 end
576 end
576 end
577 end
577 end
578 end
578
579
579 # Validates the issue against additional workflow requirements
580 # Validates the issue against additional workflow requirements
580 def validate_required_fields
581 def validate_required_fields
581 user = new_record? ? author : current_journal.try(:user)
582 user = new_record? ? author : current_journal.try(:user)
582
583
583 required_attribute_names(user).each do |attribute|
584 required_attribute_names(user).each do |attribute|
584 if attribute =~ /^\d+$/
585 if attribute =~ /^\d+$/
585 attribute = attribute.to_i
586 attribute = attribute.to_i
586 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
587 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
587 if v && v.value.blank?
588 if v && v.value.blank?
588 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
589 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
589 end
590 end
590 else
591 else
591 if respond_to?(attribute) && send(attribute).blank?
592 if respond_to?(attribute) && send(attribute).blank?
592 errors.add attribute, :blank
593 errors.add attribute, :blank
593 end
594 end
594 end
595 end
595 end
596 end
596 end
597 end
597
598
598 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
599 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
599 # even if the user turns off the setting later
600 # even if the user turns off the setting later
600 def update_done_ratio_from_issue_status
601 def update_done_ratio_from_issue_status
601 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
602 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
602 self.done_ratio = status.default_done_ratio
603 self.done_ratio = status.default_done_ratio
603 end
604 end
604 end
605 end
605
606
606 def init_journal(user, notes = "")
607 def init_journal(user, notes = "")
607 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
608 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
608 if new_record?
609 if new_record?
609 @current_journal.notify = false
610 @current_journal.notify = false
610 else
611 else
611 @attributes_before_change = attributes.dup
612 @attributes_before_change = attributes.dup
612 @custom_values_before_change = {}
613 @custom_values_before_change = {}
613 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
614 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
614 end
615 end
615 @current_journal
616 @current_journal
616 end
617 end
617
618
618 # Returns the id of the last journal or nil
619 # Returns the id of the last journal or nil
619 def last_journal_id
620 def last_journal_id
620 if new_record?
621 if new_record?
621 nil
622 nil
622 else
623 else
623 journals.maximum(:id)
624 journals.maximum(:id)
624 end
625 end
625 end
626 end
626
627
627 # Returns a scope for journals that have an id greater than journal_id
628 # Returns a scope for journals that have an id greater than journal_id
628 def journals_after(journal_id)
629 def journals_after(journal_id)
629 scope = journals.reorder("#{Journal.table_name}.id ASC")
630 scope = journals.reorder("#{Journal.table_name}.id ASC")
630 if journal_id.present?
631 if journal_id.present?
631 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
632 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
632 end
633 end
633 scope
634 scope
634 end
635 end
635
636
636 # Return true if the issue is closed, otherwise false
637 # Return true if the issue is closed, otherwise false
637 def closed?
638 def closed?
638 self.status.is_closed?
639 self.status.is_closed?
639 end
640 end
640
641
641 # Return true if the issue is being reopened
642 # Return true if the issue is being reopened
642 def reopened?
643 def reopened?
643 if !new_record? && status_id_changed?
644 if !new_record? && status_id_changed?
644 status_was = IssueStatus.find_by_id(status_id_was)
645 status_was = IssueStatus.find_by_id(status_id_was)
645 status_new = IssueStatus.find_by_id(status_id)
646 status_new = IssueStatus.find_by_id(status_id)
646 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
647 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
647 return true
648 return true
648 end
649 end
649 end
650 end
650 false
651 false
651 end
652 end
652
653
653 # Return true if the issue is being closed
654 # Return true if the issue is being closed
654 def closing?
655 def closing?
655 if !new_record? && status_id_changed?
656 if !new_record? && status_id_changed?
656 status_was = IssueStatus.find_by_id(status_id_was)
657 status_was = IssueStatus.find_by_id(status_id_was)
657 status_new = IssueStatus.find_by_id(status_id)
658 status_new = IssueStatus.find_by_id(status_id)
658 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
659 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
659 return true
660 return true
660 end
661 end
661 end
662 end
662 false
663 false
663 end
664 end
664
665
665 # Returns true if the issue is overdue
666 # Returns true if the issue is overdue
666 def overdue?
667 def overdue?
667 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
668 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
668 end
669 end
669
670
670 # Is the amount of work done less than it should for the due date
671 # Is the amount of work done less than it should for the due date
671 def behind_schedule?
672 def behind_schedule?
672 return false if start_date.nil? || due_date.nil?
673 return false if start_date.nil? || due_date.nil?
673 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
674 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
674 return done_date <= Date.today
675 return done_date <= Date.today
675 end
676 end
676
677
677 # Does this issue have children?
678 # Does this issue have children?
678 def children?
679 def children?
679 !leaf?
680 !leaf?
680 end
681 end
681
682
682 # Users the issue can be assigned to
683 # Users the issue can be assigned to
683 def assignable_users
684 def assignable_users
684 users = project.assignable_users
685 users = project.assignable_users
685 users << author if author
686 users << author if author
686 users << assigned_to if assigned_to
687 users << assigned_to if assigned_to
687 users.uniq.sort
688 users.uniq.sort
688 end
689 end
689
690
690 # Versions that the issue can be assigned to
691 # Versions that the issue can be assigned to
691 def assignable_versions
692 def assignable_versions
692 return @assignable_versions if @assignable_versions
693 return @assignable_versions if @assignable_versions
693
694
694 versions = project.shared_versions.open.all
695 versions = project.shared_versions.open.all
695 if fixed_version
696 if fixed_version
696 if fixed_version_id_changed?
697 if fixed_version_id_changed?
697 # nothing to do
698 # nothing to do
698 elsif project_id_changed?
699 elsif project_id_changed?
699 if project.shared_versions.include?(fixed_version)
700 if project.shared_versions.include?(fixed_version)
700 versions << fixed_version
701 versions << fixed_version
701 end
702 end
702 else
703 else
703 versions << fixed_version
704 versions << fixed_version
704 end
705 end
705 end
706 end
706 @assignable_versions = versions.uniq.sort
707 @assignable_versions = versions.uniq.sort
707 end
708 end
708
709
709 # Returns true if this issue is blocked by another issue that is still open
710 # Returns true if this issue is blocked by another issue that is still open
710 def blocked?
711 def blocked?
711 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
712 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
712 end
713 end
713
714
714 # Returns an array of statuses that user is able to apply
715 # Returns an array of statuses that user is able to apply
715 def new_statuses_allowed_to(user=User.current, include_default=false)
716 def new_statuses_allowed_to(user=User.current, include_default=false)
716 if new_record? && @copied_from
717 if new_record? && @copied_from
717 [IssueStatus.default, @copied_from.status].compact.uniq.sort
718 [IssueStatus.default, @copied_from.status].compact.uniq.sort
718 else
719 else
719 initial_status = nil
720 initial_status = nil
720 if new_record?
721 if new_record?
721 initial_status = IssueStatus.default
722 initial_status = IssueStatus.default
722 elsif status_id_was
723 elsif status_id_was
723 initial_status = IssueStatus.find_by_id(status_id_was)
724 initial_status = IssueStatus.find_by_id(status_id_was)
724 end
725 end
725 initial_status ||= status
726 initial_status ||= status
726
727
727 statuses = initial_status.find_new_statuses_allowed_to(
728 statuses = initial_status.find_new_statuses_allowed_to(
728 user.admin ? Role.all : user.roles_for_project(project),
729 user.admin ? Role.all : user.roles_for_project(project),
729 tracker,
730 tracker,
730 author == user,
731 author == user,
731 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
732 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
732 )
733 )
733 statuses << initial_status unless statuses.empty?
734 statuses << initial_status unless statuses.empty?
734 statuses << IssueStatus.default if include_default
735 statuses << IssueStatus.default if include_default
735 statuses = statuses.compact.uniq.sort
736 statuses = statuses.compact.uniq.sort
736 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
737 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
737 end
738 end
738 end
739 end
739
740
740 def assigned_to_was
741 def assigned_to_was
741 if assigned_to_id_changed? && assigned_to_id_was.present?
742 if assigned_to_id_changed? && assigned_to_id_was.present?
742 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
743 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
743 end
744 end
744 end
745 end
745
746
746 # Returns the users that should be notified
747 # Returns the users that should be notified
747 def notified_users
748 def notified_users
748 notified = []
749 notified = []
749 # Author and assignee are always notified unless they have been
750 # Author and assignee are always notified unless they have been
750 # locked or don't want to be notified
751 # locked or don't want to be notified
751 notified << author if author
752 notified << author if author
752 if assigned_to
753 if assigned_to
753 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
754 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
754 end
755 end
755 if assigned_to_was
756 if assigned_to_was
756 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
757 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
757 end
758 end
758 notified = notified.select {|u| u.active? && u.notify_about?(self)}
759 notified = notified.select {|u| u.active? && u.notify_about?(self)}
759
760
760 notified += project.notified_users
761 notified += project.notified_users
761 notified.uniq!
762 notified.uniq!
762 # Remove users that can not view the issue
763 # Remove users that can not view the issue
763 notified.reject! {|user| !visible?(user)}
764 notified.reject! {|user| !visible?(user)}
764 notified
765 notified
765 end
766 end
766
767
767 # Returns the email addresses that should be notified
768 # Returns the email addresses that should be notified
768 def recipients
769 def recipients
769 notified_users.collect(&:mail)
770 notified_users.collect(&:mail)
770 end
771 end
771
772
772 # Returns the number of hours spent on this issue
773 # Returns the number of hours spent on this issue
773 def spent_hours
774 def spent_hours
774 @spent_hours ||= time_entries.sum(:hours) || 0
775 @spent_hours ||= time_entries.sum(:hours) || 0
775 end
776 end
776
777
777 # Returns the total number of hours spent on this issue and its descendants
778 # Returns the total number of hours spent on this issue and its descendants
778 #
779 #
779 # Example:
780 # Example:
780 # spent_hours => 0.0
781 # spent_hours => 0.0
781 # spent_hours => 50.2
782 # spent_hours => 50.2
782 def total_spent_hours
783 def total_spent_hours
783 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
784 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
784 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
785 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
785 end
786 end
786
787
787 def relations
788 def relations
788 @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort)
789 @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort)
789 end
790 end
790
791
791 # Preloads relations for a collection of issues
792 # Preloads relations for a collection of issues
792 def self.load_relations(issues)
793 def self.load_relations(issues)
793 if issues.any?
794 if issues.any?
794 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
795 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
795 issues.each do |issue|
796 issues.each do |issue|
796 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
797 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
797 end
798 end
798 end
799 end
799 end
800 end
800
801
801 # Preloads visible spent time for a collection of issues
802 # Preloads visible spent time for a collection of issues
802 def self.load_visible_spent_hours(issues, user=User.current)
803 def self.load_visible_spent_hours(issues, user=User.current)
803 if issues.any?
804 if issues.any?
804 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
805 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
805 issues.each do |issue|
806 issues.each do |issue|
806 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
807 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
807 end
808 end
808 end
809 end
809 end
810 end
810
811
811 # Preloads visible relations for a collection of issues
812 # Preloads visible relations for a collection of issues
812 def self.load_visible_relations(issues, user=User.current)
813 def self.load_visible_relations(issues, user=User.current)
813 if issues.any?
814 if issues.any?
814 issue_ids = issues.map(&:id)
815 issue_ids = issues.map(&:id)
815 # Relations with issue_from in given issues and visible issue_to
816 # Relations with issue_from in given issues and visible issue_to
816 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
817 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
817 # Relations with issue_to in given issues and visible issue_from
818 # Relations with issue_to in given issues and visible issue_from
818 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
819 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
819
820
820 issues.each do |issue|
821 issues.each do |issue|
821 relations =
822 relations =
822 relations_from.select {|relation| relation.issue_from_id == issue.id} +
823 relations_from.select {|relation| relation.issue_from_id == issue.id} +
823 relations_to.select {|relation| relation.issue_to_id == issue.id}
824 relations_to.select {|relation| relation.issue_to_id == issue.id}
824
825
825 issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort)
826 issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort)
826 end
827 end
827 end
828 end
828 end
829 end
829
830
830 # Finds an issue relation given its id.
831 # Finds an issue relation given its id.
831 def find_relation(relation_id)
832 def find_relation(relation_id)
832 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
833 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
833 end
834 end
834
835
835 def all_dependent_issues(except=[])
836 def all_dependent_issues(except=[])
836 except << self
837 except << self
837 dependencies = []
838 dependencies = []
838 relations_from.each do |relation|
839 relations_from.each do |relation|
839 if relation.issue_to && !except.include?(relation.issue_to)
840 if relation.issue_to && !except.include?(relation.issue_to)
840 dependencies << relation.issue_to
841 dependencies << relation.issue_to
841 dependencies += relation.issue_to.all_dependent_issues(except)
842 dependencies += relation.issue_to.all_dependent_issues(except)
842 end
843 end
843 end
844 end
844 dependencies
845 dependencies
845 end
846 end
846
847
847 # Returns an array of issues that duplicate this one
848 # Returns an array of issues that duplicate this one
848 def duplicates
849 def duplicates
849 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
850 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
850 end
851 end
851
852
852 # Returns the due date or the target due date if any
853 # Returns the due date or the target due date if any
853 # Used on gantt chart
854 # Used on gantt chart
854 def due_before
855 def due_before
855 due_date || (fixed_version ? fixed_version.effective_date : nil)
856 due_date || (fixed_version ? fixed_version.effective_date : nil)
856 end
857 end
857
858
858 # Returns the time scheduled for this issue.
859 # Returns the time scheduled for this issue.
859 #
860 #
860 # Example:
861 # Example:
861 # Start Date: 2/26/09, End Date: 3/04/09
862 # Start Date: 2/26/09, End Date: 3/04/09
862 # duration => 6
863 # duration => 6
863 def duration
864 def duration
864 (start_date && due_date) ? due_date - start_date : 0
865 (start_date && due_date) ? due_date - start_date : 0
865 end
866 end
866
867
867 # Returns the duration in working days
868 # Returns the duration in working days
868 def working_duration
869 def working_duration
869 (start_date && due_date) ? working_days(start_date, due_date) : 0
870 (start_date && due_date) ? working_days(start_date, due_date) : 0
870 end
871 end
871
872
872 def soonest_start(reload=false)
873 def soonest_start(reload=false)
873 @soonest_start = nil if reload
874 @soonest_start = nil if reload
874 @soonest_start ||= (
875 @soonest_start ||= (
875 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
876 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
876 ancestors.collect(&:soonest_start)
877 ancestors.collect(&:soonest_start)
877 ).compact.max
878 ).compact.max
878 end
879 end
879
880
880 # Sets start_date on the given date or the next working day
881 # Sets start_date on the given date or the next working day
881 # and changes due_date to keep the same working duration.
882 # and changes due_date to keep the same working duration.
882 def reschedule_on(date)
883 def reschedule_on(date)
883 wd = working_duration
884 wd = working_duration
884 date = next_working_date(date)
885 date = next_working_date(date)
885 self.start_date = date
886 self.start_date = date
886 self.due_date = add_working_days(date, wd)
887 self.due_date = add_working_days(date, wd)
887 end
888 end
888
889
889 # Reschedules the issue on the given date or the next working day and saves the record.
890 # Reschedules the issue on the given date or the next working day and saves the record.
890 # If the issue is a parent task, this is done by rescheduling its subtasks.
891 # If the issue is a parent task, this is done by rescheduling its subtasks.
891 def reschedule_on!(date)
892 def reschedule_on!(date)
892 return if date.nil?
893 return if date.nil?
893 if leaf?
894 if leaf?
894 if start_date.nil? || start_date != date
895 if start_date.nil? || start_date != date
895 if start_date && start_date > date
896 if start_date && start_date > date
896 # Issue can not be moved earlier than its soonest start date
897 # Issue can not be moved earlier than its soonest start date
897 date = [soonest_start(true), date].compact.max
898 date = [soonest_start(true), date].compact.max
898 end
899 end
899 reschedule_on(date)
900 reschedule_on(date)
900 begin
901 begin
901 save
902 save
902 rescue ActiveRecord::StaleObjectError
903 rescue ActiveRecord::StaleObjectError
903 reload
904 reload
904 reschedule_on(date)
905 reschedule_on(date)
905 save
906 save
906 end
907 end
907 end
908 end
908 else
909 else
909 leaves.each do |leaf|
910 leaves.each do |leaf|
910 if leaf.start_date
911 if leaf.start_date
911 # Only move subtask if it starts at the same date as the parent
912 # Only move subtask if it starts at the same date as the parent
912 # or if it starts before the given date
913 # or if it starts before the given date
913 if start_date == leaf.start_date || date > leaf.start_date
914 if start_date == leaf.start_date || date > leaf.start_date
914 leaf.reschedule_on!(date)
915 leaf.reschedule_on!(date)
915 end
916 end
916 else
917 else
917 leaf.reschedule_on!(date)
918 leaf.reschedule_on!(date)
918 end
919 end
919 end
920 end
920 end
921 end
921 end
922 end
922
923
923 def <=>(issue)
924 def <=>(issue)
924 if issue.nil?
925 if issue.nil?
925 -1
926 -1
926 elsif root_id != issue.root_id
927 elsif root_id != issue.root_id
927 (root_id || 0) <=> (issue.root_id || 0)
928 (root_id || 0) <=> (issue.root_id || 0)
928 else
929 else
929 (lft || 0) <=> (issue.lft || 0)
930 (lft || 0) <=> (issue.lft || 0)
930 end
931 end
931 end
932 end
932
933
933 def to_s
934 def to_s
934 "#{tracker} ##{id}: #{subject}"
935 "#{tracker} ##{id}: #{subject}"
935 end
936 end
936
937
937 # Returns a string of css classes that apply to the issue
938 # Returns a string of css classes that apply to the issue
938 def css_classes
939 def css_classes
939 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
940 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
940 s << ' closed' if closed?
941 s << ' closed' if closed?
941 s << ' overdue' if overdue?
942 s << ' overdue' if overdue?
942 s << ' child' if child?
943 s << ' child' if child?
943 s << ' parent' unless leaf?
944 s << ' parent' unless leaf?
944 s << ' private' if is_private?
945 s << ' private' if is_private?
945 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
946 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
946 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
947 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
947 s
948 s
948 end
949 end
949
950
950 # Saves an issue and a time_entry from the parameters
951 # Saves an issue and a time_entry from the parameters
951 def save_issue_with_child_records(params, existing_time_entry=nil)
952 def save_issue_with_child_records(params, existing_time_entry=nil)
952 Issue.transaction do
953 Issue.transaction do
953 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
954 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
954 @time_entry = existing_time_entry || TimeEntry.new
955 @time_entry = existing_time_entry || TimeEntry.new
955 @time_entry.project = project
956 @time_entry.project = project
956 @time_entry.issue = self
957 @time_entry.issue = self
957 @time_entry.user = User.current
958 @time_entry.user = User.current
958 @time_entry.spent_on = User.current.today
959 @time_entry.spent_on = User.current.today
959 @time_entry.attributes = params[:time_entry]
960 @time_entry.attributes = params[:time_entry]
960 self.time_entries << @time_entry
961 self.time_entries << @time_entry
961 end
962 end
962
963
963 # TODO: Rename hook
964 # TODO: Rename hook
964 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
965 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
965 if save
966 if save
966 # TODO: Rename hook
967 # TODO: Rename hook
967 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
968 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
968 else
969 else
969 raise ActiveRecord::Rollback
970 raise ActiveRecord::Rollback
970 end
971 end
971 end
972 end
972 end
973 end
973
974
974 # Unassigns issues from +version+ if it's no longer shared with issue's project
975 # Unassigns issues from +version+ if it's no longer shared with issue's project
975 def self.update_versions_from_sharing_change(version)
976 def self.update_versions_from_sharing_change(version)
976 # Update issues assigned to the version
977 # Update issues assigned to the version
977 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
978 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
978 end
979 end
979
980
980 # Unassigns issues from versions that are no longer shared
981 # Unassigns issues from versions that are no longer shared
981 # after +project+ was moved
982 # after +project+ was moved
982 def self.update_versions_from_hierarchy_change(project)
983 def self.update_versions_from_hierarchy_change(project)
983 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
984 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
984 # Update issues of the moved projects and issues assigned to a version of a moved project
985 # Update issues of the moved projects and issues assigned to a version of a moved project
985 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
986 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
986 end
987 end
987
988
988 def parent_issue_id=(arg)
989 def parent_issue_id=(arg)
989 s = arg.to_s.strip.presence
990 s = arg.to_s.strip.presence
990 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
991 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
991 @parent_issue.id
992 @parent_issue.id
992 else
993 else
993 @parent_issue = nil
994 @parent_issue = nil
994 @invalid_parent_issue_id = arg
995 @invalid_parent_issue_id = arg
995 end
996 end
996 end
997 end
997
998
998 def parent_issue_id
999 def parent_issue_id
999 if @invalid_parent_issue_id
1000 if @invalid_parent_issue_id
1000 @invalid_parent_issue_id
1001 @invalid_parent_issue_id
1001 elsif instance_variable_defined? :@parent_issue
1002 elsif instance_variable_defined? :@parent_issue
1002 @parent_issue.nil? ? nil : @parent_issue.id
1003 @parent_issue.nil? ? nil : @parent_issue.id
1003 else
1004 else
1004 parent_id
1005 parent_id
1005 end
1006 end
1006 end
1007 end
1007
1008
1008 # Returns true if issue's project is a valid
1009 # Returns true if issue's project is a valid
1009 # parent issue project
1010 # parent issue project
1010 def valid_parent_project?(issue=parent)
1011 def valid_parent_project?(issue=parent)
1011 return true if issue.nil? || issue.project_id == project_id
1012 return true if issue.nil? || issue.project_id == project_id
1012
1013
1013 case Setting.cross_project_subtasks
1014 case Setting.cross_project_subtasks
1014 when 'system'
1015 when 'system'
1015 true
1016 true
1016 when 'tree'
1017 when 'tree'
1017 issue.project.root == project.root
1018 issue.project.root == project.root
1018 when 'hierarchy'
1019 when 'hierarchy'
1019 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1020 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1020 when 'descendants'
1021 when 'descendants'
1021 issue.project.is_or_is_ancestor_of?(project)
1022 issue.project.is_or_is_ancestor_of?(project)
1022 else
1023 else
1023 false
1024 false
1024 end
1025 end
1025 end
1026 end
1026
1027
1027 # Extracted from the ReportsController.
1028 # Extracted from the ReportsController.
1028 def self.by_tracker(project)
1029 def self.by_tracker(project)
1029 count_and_group_by(:project => project,
1030 count_and_group_by(:project => project,
1030 :field => 'tracker_id',
1031 :field => 'tracker_id',
1031 :joins => Tracker.table_name)
1032 :joins => Tracker.table_name)
1032 end
1033 end
1033
1034
1034 def self.by_version(project)
1035 def self.by_version(project)
1035 count_and_group_by(:project => project,
1036 count_and_group_by(:project => project,
1036 :field => 'fixed_version_id',
1037 :field => 'fixed_version_id',
1037 :joins => Version.table_name)
1038 :joins => Version.table_name)
1038 end
1039 end
1039
1040
1040 def self.by_priority(project)
1041 def self.by_priority(project)
1041 count_and_group_by(:project => project,
1042 count_and_group_by(:project => project,
1042 :field => 'priority_id',
1043 :field => 'priority_id',
1043 :joins => IssuePriority.table_name)
1044 :joins => IssuePriority.table_name)
1044 end
1045 end
1045
1046
1046 def self.by_category(project)
1047 def self.by_category(project)
1047 count_and_group_by(:project => project,
1048 count_and_group_by(:project => project,
1048 :field => 'category_id',
1049 :field => 'category_id',
1049 :joins => IssueCategory.table_name)
1050 :joins => IssueCategory.table_name)
1050 end
1051 end
1051
1052
1052 def self.by_assigned_to(project)
1053 def self.by_assigned_to(project)
1053 count_and_group_by(:project => project,
1054 count_and_group_by(:project => project,
1054 :field => 'assigned_to_id',
1055 :field => 'assigned_to_id',
1055 :joins => User.table_name)
1056 :joins => User.table_name)
1056 end
1057 end
1057
1058
1058 def self.by_author(project)
1059 def self.by_author(project)
1059 count_and_group_by(:project => project,
1060 count_and_group_by(:project => project,
1060 :field => 'author_id',
1061 :field => 'author_id',
1061 :joins => User.table_name)
1062 :joins => User.table_name)
1062 end
1063 end
1063
1064
1064 def self.by_subproject(project)
1065 def self.by_subproject(project)
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 #{Issue.table_name}.project_id as project_id,
1068 #{Issue.table_name}.project_id as project_id,
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
1071 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1071 where
1072 where
1072 #{Issue.table_name}.status_id=s.id
1073 #{Issue.table_name}.status_id=s.id
1073 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1074 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1074 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1075 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1075 and #{Issue.table_name}.project_id <> #{project.id}
1076 and #{Issue.table_name}.project_id <> #{project.id}
1076 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1077 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1077 end
1078 end
1078 # End ReportsController extraction
1079 # End ReportsController extraction
1079
1080
1080 # Returns an array of projects that user can assign the issue to
1081 # Returns an array of projects that user can assign the issue to
1081 def allowed_target_projects(user=User.current)
1082 def allowed_target_projects(user=User.current)
1082 if new_record?
1083 if new_record?
1083 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
1084 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
1084 else
1085 else
1085 self.class.allowed_target_projects_on_move(user)
1086 self.class.allowed_target_projects_on_move(user)
1086 end
1087 end
1087 end
1088 end
1088
1089
1089 # Returns an array of projects that user can move issues to
1090 # Returns an array of projects that user can move issues to
1090 def self.allowed_target_projects_on_move(user=User.current)
1091 def self.allowed_target_projects_on_move(user=User.current)
1091 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
1092 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
1092 end
1093 end
1093
1094
1094 private
1095 private
1095
1096
1096 def after_project_change
1097 def after_project_change
1097 # Update project_id on related time entries
1098 # Update project_id on related time entries
1098 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1099 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1099
1100
1100 # Delete issue relations
1101 # Delete issue relations
1101 unless Setting.cross_project_issue_relations?
1102 unless Setting.cross_project_issue_relations?
1102 relations_from.clear
1103 relations_from.clear
1103 relations_to.clear
1104 relations_to.clear
1104 end
1105 end
1105
1106
1106 # Move subtasks that were in the same project
1107 # Move subtasks that were in the same project
1107 children.each do |child|
1108 children.each do |child|
1108 next unless child.project_id == project_id_was
1109 next unless child.project_id == project_id_was
1109 # Change project and keep project
1110 # Change project and keep project
1110 child.send :project=, project, true
1111 child.send :project=, project, true
1111 unless child.save
1112 unless child.save
1112 raise ActiveRecord::Rollback
1113 raise ActiveRecord::Rollback
1113 end
1114 end
1114 end
1115 end
1115 end
1116 end
1116
1117
1117 # Callback for after the creation of an issue by copy
1118 # Callback for after the creation of an issue by copy
1118 # * adds a "copied to" relation with the copied issue
1119 # * adds a "copied to" relation with the copied issue
1119 # * copies subtasks from the copied issue
1120 # * copies subtasks from the copied issue
1120 def after_create_from_copy
1121 def after_create_from_copy
1121 return unless copy? && !@after_create_from_copy_handled
1122 return unless copy? && !@after_create_from_copy_handled
1122
1123
1123 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1124 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1124 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1125 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1125 unless relation.save
1126 unless relation.save
1126 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1127 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1127 end
1128 end
1128 end
1129 end
1129
1130
1130 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1131 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1131 @copied_from.children.each do |child|
1132 @copied_from.children.each do |child|
1132 unless child.visible?
1133 unless child.visible?
1133 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1134 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1134 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1135 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1135 next
1136 next
1136 end
1137 end
1137 copy = Issue.new.copy_from(child, @copy_options)
1138 copy = Issue.new.copy_from(child, @copy_options)
1138 copy.author = author
1139 copy.author = author
1139 copy.project = project
1140 copy.project = project
1140 copy.parent_issue_id = id
1141 copy.parent_issue_id = id
1141 # Children subtasks are copied recursively
1142 # Children subtasks are copied recursively
1142 unless copy.save
1143 unless copy.save
1143 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1144 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1144 end
1145 end
1145 end
1146 end
1146 end
1147 end
1147 @after_create_from_copy_handled = true
1148 @after_create_from_copy_handled = true
1148 end
1149 end
1149
1150
1150 def update_nested_set_attributes
1151 def update_nested_set_attributes
1151 if root_id.nil?
1152 if root_id.nil?
1152 # issue was just created
1153 # issue was just created
1153 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1154 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1154 set_default_left_and_right
1155 set_default_left_and_right
1155 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1156 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1156 if @parent_issue
1157 if @parent_issue
1157 move_to_child_of(@parent_issue)
1158 move_to_child_of(@parent_issue)
1158 end
1159 end
1159 reload
1160 reload
1160 elsif parent_issue_id != parent_id
1161 elsif parent_issue_id != parent_id
1161 former_parent_id = parent_id
1162 former_parent_id = parent_id
1162 # moving an existing issue
1163 # moving an existing issue
1163 if @parent_issue && @parent_issue.root_id == root_id
1164 if @parent_issue && @parent_issue.root_id == root_id
1164 # inside the same tree
1165 # inside the same tree
1165 move_to_child_of(@parent_issue)
1166 move_to_child_of(@parent_issue)
1166 else
1167 else
1167 # to another tree
1168 # to another tree
1168 unless root?
1169 unless root?
1169 move_to_right_of(root)
1170 move_to_right_of(root)
1170 reload
1171 reload
1171 end
1172 end
1172 old_root_id = root_id
1173 old_root_id = root_id
1173 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1174 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1174 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1175 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1175 offset = target_maxright + 1 - lft
1176 offset = target_maxright + 1 - lft
1176 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1177 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1177 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1178 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1178 self[left_column_name] = lft + offset
1179 self[left_column_name] = lft + offset
1179 self[right_column_name] = rgt + offset
1180 self[right_column_name] = rgt + offset
1180 if @parent_issue
1181 if @parent_issue
1181 move_to_child_of(@parent_issue)
1182 move_to_child_of(@parent_issue)
1182 end
1183 end
1183 end
1184 end
1184 reload
1185 reload
1185 # delete invalid relations of all descendants
1186 # delete invalid relations of all descendants
1186 self_and_descendants.each do |issue|
1187 self_and_descendants.each do |issue|
1187 issue.relations.each do |relation|
1188 issue.relations.each do |relation|
1188 relation.destroy unless relation.valid?
1189 relation.destroy unless relation.valid?
1189 end
1190 end
1190 end
1191 end
1191 # update former parent
1192 # update former parent
1192 recalculate_attributes_for(former_parent_id) if former_parent_id
1193 recalculate_attributes_for(former_parent_id) if former_parent_id
1193 end
1194 end
1194 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1195 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1195 end
1196 end
1196
1197
1197 def update_parent_attributes
1198 def update_parent_attributes
1198 recalculate_attributes_for(parent_id) if parent_id
1199 recalculate_attributes_for(parent_id) if parent_id
1199 end
1200 end
1200
1201
1201 def recalculate_attributes_for(issue_id)
1202 def recalculate_attributes_for(issue_id)
1202 if issue_id && p = Issue.find_by_id(issue_id)
1203 if issue_id && p = Issue.find_by_id(issue_id)
1203 # priority = highest priority of children
1204 # priority = highest priority of children
1204 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1205 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1205 p.priority = IssuePriority.find_by_position(priority_position)
1206 p.priority = IssuePriority.find_by_position(priority_position)
1206 end
1207 end
1207
1208
1208 # start/due dates = lowest/highest dates of children
1209 # start/due dates = lowest/highest dates of children
1209 p.start_date = p.children.minimum(:start_date)
1210 p.start_date = p.children.minimum(:start_date)
1210 p.due_date = p.children.maximum(:due_date)
1211 p.due_date = p.children.maximum(:due_date)
1211 if p.start_date && p.due_date && p.due_date < p.start_date
1212 if p.start_date && p.due_date && p.due_date < p.start_date
1212 p.start_date, p.due_date = p.due_date, p.start_date
1213 p.start_date, p.due_date = p.due_date, p.start_date
1213 end
1214 end
1214
1215
1215 # done ratio = weighted average ratio of leaves
1216 # done ratio = weighted average ratio of leaves
1216 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1217 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1217 leaves_count = p.leaves.count
1218 leaves_count = p.leaves.count
1218 if leaves_count > 0
1219 if leaves_count > 0
1219 average = p.leaves.average(:estimated_hours).to_f
1220 average = p.leaves.average(:estimated_hours).to_f
1220 if average == 0
1221 if average == 0
1221 average = 1
1222 average = 1
1222 end
1223 end
1223 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
1224 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
1224 progress = done / (average * leaves_count)
1225 progress = done / (average * leaves_count)
1225 p.done_ratio = progress.round
1226 p.done_ratio = progress.round
1226 end
1227 end
1227 end
1228 end
1228
1229
1229 # estimate = sum of leaves estimates
1230 # estimate = sum of leaves estimates
1230 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1231 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1231 p.estimated_hours = nil if p.estimated_hours == 0.0
1232 p.estimated_hours = nil if p.estimated_hours == 0.0
1232
1233
1233 # ancestors will be recursively updated
1234 # ancestors will be recursively updated
1234 p.save(:validate => false)
1235 p.save(:validate => false)
1235 end
1236 end
1236 end
1237 end
1237
1238
1238 # Update issues so their versions are not pointing to a
1239 # Update issues so their versions are not pointing to a
1239 # fixed_version that is not shared with the issue's project
1240 # fixed_version that is not shared with the issue's project
1240 def self.update_versions(conditions=nil)
1241 def self.update_versions(conditions=nil)
1241 # Only need to update issues with a fixed_version from
1242 # Only need to update issues with a fixed_version from
1242 # a different project and that is not systemwide shared
1243 # a different project and that is not systemwide shared
1243 Issue.scoped(:conditions => conditions).all(
1244 Issue.scoped(:conditions => conditions).all(
1244 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1245 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1245 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1246 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1246 " AND #{Version.table_name}.sharing <> 'system'",
1247 " AND #{Version.table_name}.sharing <> 'system'",
1247 :include => [:project, :fixed_version]
1248 :include => [:project, :fixed_version]
1248 ).each do |issue|
1249 ).each do |issue|
1249 next if issue.project.nil? || issue.fixed_version.nil?
1250 next if issue.project.nil? || issue.fixed_version.nil?
1250 unless issue.project.shared_versions.include?(issue.fixed_version)
1251 unless issue.project.shared_versions.include?(issue.fixed_version)
1251 issue.init_journal(User.current)
1252 issue.init_journal(User.current)
1252 issue.fixed_version = nil
1253 issue.fixed_version = nil
1253 issue.save
1254 issue.save
1254 end
1255 end
1255 end
1256 end
1256 end
1257 end
1257
1258
1258 # Callback on file attachment
1259 # Callback on file attachment
1259 def attachment_added(obj)
1260 def attachment_added(obj)
1260 if @current_journal && !obj.new_record?
1261 if @current_journal && !obj.new_record?
1261 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1262 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1262 end
1263 end
1263 end
1264 end
1264
1265
1265 # Callback on attachment deletion
1266 # Callback on attachment deletion
1266 def attachment_removed(obj)
1267 def attachment_removed(obj)
1267 if @current_journal && !obj.new_record?
1268 if @current_journal && !obj.new_record?
1268 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1269 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1269 @current_journal.save
1270 @current_journal.save
1270 end
1271 end
1271 end
1272 end
1272
1273
1273 # Default assignment based on category
1274 # Default assignment based on category
1274 def default_assign
1275 def default_assign
1275 if assigned_to.nil? && category && category.assigned_to
1276 if assigned_to.nil? && category && category.assigned_to
1276 self.assigned_to = category.assigned_to
1277 self.assigned_to = category.assigned_to
1277 end
1278 end
1278 end
1279 end
1279
1280
1280 # Updates start/due dates of following issues
1281 # Updates start/due dates of following issues
1281 def reschedule_following_issues
1282 def reschedule_following_issues
1282 if start_date_changed? || due_date_changed?
1283 if start_date_changed? || due_date_changed?
1283 relations_from.each do |relation|
1284 relations_from.each do |relation|
1284 relation.set_issue_to_dates
1285 relation.set_issue_to_dates
1285 end
1286 end
1286 end
1287 end
1287 end
1288 end
1288
1289
1289 # Closes duplicates if the issue is being closed
1290 # Closes duplicates if the issue is being closed
1290 def close_duplicates
1291 def close_duplicates
1291 if closing?
1292 if closing?
1292 duplicates.each do |duplicate|
1293 duplicates.each do |duplicate|
1293 # Reload is need in case the duplicate was updated by a previous duplicate
1294 # Reload is need in case the duplicate was updated by a previous duplicate
1294 duplicate.reload
1295 duplicate.reload
1295 # Don't re-close it if it's already closed
1296 # Don't re-close it if it's already closed
1296 next if duplicate.closed?
1297 next if duplicate.closed?
1297 # Same user and notes
1298 # Same user and notes
1298 if @current_journal
1299 if @current_journal
1299 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1300 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1300 end
1301 end
1301 duplicate.update_attribute :status, self.status
1302 duplicate.update_attribute :status, self.status
1302 end
1303 end
1303 end
1304 end
1304 end
1305 end
1305
1306
1306 # Make sure updated_on is updated when adding a note
1307 # Make sure updated_on is updated when adding a note
1307 def force_updated_on_change
1308 def force_updated_on_change
1308 if @current_journal
1309 if @current_journal
1309 self.updated_on = current_time_from_proper_timezone
1310 self.updated_on = current_time_from_proper_timezone
1310 end
1311 end
1311 end
1312 end
1312
1313
1313 # Saves the changes in a Journal
1314 # Saves the changes in a Journal
1314 # Called after_save
1315 # Called after_save
1315 def create_journal
1316 def create_journal
1316 if @current_journal
1317 if @current_journal
1317 # attributes changes
1318 # attributes changes
1318 if @attributes_before_change
1319 if @attributes_before_change
1319 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1320 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1320 before = @attributes_before_change[c]
1321 before = @attributes_before_change[c]
1321 after = send(c)
1322 after = send(c)
1322 next if before == after || (before.blank? && after.blank?)
1323 next if before == after || (before.blank? && after.blank?)
1323 @current_journal.details << JournalDetail.new(:property => 'attr',
1324 @current_journal.details << JournalDetail.new(:property => 'attr',
1324 :prop_key => c,
1325 :prop_key => c,
1325 :old_value => before,
1326 :old_value => before,
1326 :value => after)
1327 :value => after)
1327 }
1328 }
1328 end
1329 end
1329 if @custom_values_before_change
1330 if @custom_values_before_change
1330 # custom fields changes
1331 # custom fields changes
1331 custom_field_values.each {|c|
1332 custom_field_values.each {|c|
1332 before = @custom_values_before_change[c.custom_field_id]
1333 before = @custom_values_before_change[c.custom_field_id]
1333 after = c.value
1334 after = c.value
1334 next if before == after || (before.blank? && after.blank?)
1335 next if before == after || (before.blank? && after.blank?)
1335
1336
1336 if before.is_a?(Array) || after.is_a?(Array)
1337 if before.is_a?(Array) || after.is_a?(Array)
1337 before = [before] unless before.is_a?(Array)
1338 before = [before] unless before.is_a?(Array)
1338 after = [after] unless after.is_a?(Array)
1339 after = [after] unless after.is_a?(Array)
1339
1340
1340 # values removed
1341 # values removed
1341 (before - after).reject(&:blank?).each do |value|
1342 (before - after).reject(&:blank?).each do |value|
1342 @current_journal.details << JournalDetail.new(:property => 'cf',
1343 @current_journal.details << JournalDetail.new(:property => 'cf',
1343 :prop_key => c.custom_field_id,
1344 :prop_key => c.custom_field_id,
1344 :old_value => value,
1345 :old_value => value,
1345 :value => nil)
1346 :value => nil)
1346 end
1347 end
1347 # values added
1348 # values added
1348 (after - before).reject(&:blank?).each do |value|
1349 (after - before).reject(&:blank?).each do |value|
1349 @current_journal.details << JournalDetail.new(:property => 'cf',
1350 @current_journal.details << JournalDetail.new(:property => 'cf',
1350 :prop_key => c.custom_field_id,
1351 :prop_key => c.custom_field_id,
1351 :old_value => nil,
1352 :old_value => nil,
1352 :value => value)
1353 :value => value)
1353 end
1354 end
1354 else
1355 else
1355 @current_journal.details << JournalDetail.new(:property => 'cf',
1356 @current_journal.details << JournalDetail.new(:property => 'cf',
1356 :prop_key => c.custom_field_id,
1357 :prop_key => c.custom_field_id,
1357 :old_value => before,
1358 :old_value => before,
1358 :value => after)
1359 :value => after)
1359 end
1360 end
1360 }
1361 }
1361 end
1362 end
1362 @current_journal.save
1363 @current_journal.save
1363 # reset current journal
1364 # reset current journal
1364 init_journal @current_journal.user, @current_journal.notes
1365 init_journal @current_journal.user, @current_journal.notes
1365 end
1366 end
1366 end
1367 end
1367
1368
1368 # Query generator for selecting groups of issue counts for a project
1369 # Query generator for selecting groups of issue counts for a project
1369 # based on specific criteria
1370 # based on specific criteria
1370 #
1371 #
1371 # Options
1372 # Options
1372 # * project - Project to search in.
1373 # * project - Project to search in.
1373 # * field - String. Issue field to key off of in the grouping.
1374 # * field - String. Issue field to key off of in the grouping.
1374 # * joins - String. The table name to join against.
1375 # * joins - String. The table name to join against.
1375 def self.count_and_group_by(options)
1376 def self.count_and_group_by(options)
1376 project = options.delete(:project)
1377 project = options.delete(:project)
1377 select_field = options.delete(:field)
1378 select_field = options.delete(:field)
1378 joins = options.delete(:joins)
1379 joins = options.delete(:joins)
1379
1380
1380 where = "#{Issue.table_name}.#{select_field}=j.id"
1381 where = "#{Issue.table_name}.#{select_field}=j.id"
1381
1382
1382 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1383 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1383 s.is_closed as closed,
1384 s.is_closed as closed,
1384 j.id as #{select_field},
1385 j.id as #{select_field},
1385 count(#{Issue.table_name}.id) as total
1386 count(#{Issue.table_name}.id) as total
1386 from
1387 from
1387 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1388 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1388 where
1389 where
1389 #{Issue.table_name}.status_id=s.id
1390 #{Issue.table_name}.status_id=s.id
1390 and #{where}
1391 and #{where}
1391 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1392 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1392 and #{visible_condition(User.current, :project => project)}
1393 and #{visible_condition(User.current, :project => project)}
1393 group by s.id, s.is_closed, j.id")
1394 group by s.id, s.is_closed, j.id")
1394 end
1395 end
1395 end
1396 end
@@ -1,106 +1,107
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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, :reset_counters!
44 after_create :add_author_as_watcher, :reset_counters!
45 after_update :update_messages_board
45 after_update :update_messages_board
46 after_destroy :reset_counters!
46 after_destroy :reset_counters!
47
47
48 scope :visible, lambda {|*args| { :include => {:board => :project},
48 scope :visible, lambda {|*args|
49 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
49 includes(:board => :project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
50 }
50
51
51 safe_attributes 'subject', 'content'
52 safe_attributes 'subject', 'content'
52 safe_attributes 'locked', 'sticky', 'board_id',
53 safe_attributes 'locked', 'sticky', 'board_id',
53 :if => lambda {|message, user|
54 :if => lambda {|message, user|
54 user.allowed_to?(:edit_messages, message.project)
55 user.allowed_to?(:edit_messages, message.project)
55 }
56 }
56
57
57 def visible?(user=User.current)
58 def visible?(user=User.current)
58 !user.nil? && user.allowed_to?(:view_messages, project)
59 !user.nil? && user.allowed_to?(:view_messages, project)
59 end
60 end
60
61
61 def cannot_reply_to_locked_topic
62 def cannot_reply_to_locked_topic
62 # Can not reply to a locked topic
63 # Can not reply to a locked topic
63 errors.add :base, 'Topic is locked' if root.locked? && self != root
64 errors.add :base, 'Topic is locked' if root.locked? && self != root
64 end
65 end
65
66
66 def update_messages_board
67 def update_messages_board
67 if board_id_changed?
68 if board_id_changed?
68 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
69 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
69 Board.reset_counters!(board_id_was)
70 Board.reset_counters!(board_id_was)
70 Board.reset_counters!(board_id)
71 Board.reset_counters!(board_id)
71 end
72 end
72 end
73 end
73
74
74 def reset_counters!
75 def reset_counters!
75 if parent && parent.id
76 if parent && parent.id
76 Message.update_all({:last_reply_id => parent.children.maximum(:id)}, {:id => parent.id})
77 Message.update_all({:last_reply_id => parent.children.maximum(:id)}, {:id => parent.id})
77 end
78 end
78 board.reset_counters!
79 board.reset_counters!
79 end
80 end
80
81
81 def sticky=(arg)
82 def sticky=(arg)
82 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
83 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
83 end
84 end
84
85
85 def sticky?
86 def sticky?
86 sticky == 1
87 sticky == 1
87 end
88 end
88
89
89 def project
90 def project
90 board.project
91 board.project
91 end
92 end
92
93
93 def editable_by?(usr)
94 def editable_by?(usr)
94 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
95 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
95 end
96 end
96
97
97 def destroyable_by?(usr)
98 def destroyable_by?(usr)
98 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
99 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
99 end
100 end
100
101
101 private
102 private
102
103
103 def add_author_as_watcher
104 def add_author_as_watcher
104 Watcher.create(:watchable => self.root, :user => author)
105 Watcher.create(:watchable => self.root, :user => author)
105 end
106 end
106 end
107 end
@@ -1,63 +1,62
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 scope :visible, lambda {|*args| {
37 scope :visible, lambda {|*args|
38 :include => :project,
38 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args))
39 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_news, *args)
39 }
40 }}
41
40
42 safe_attributes 'title', 'summary', 'description'
41 safe_attributes 'title', 'summary', 'description'
43
42
44 def visible?(user=User.current)
43 def visible?(user=User.current)
45 !user.nil? && user.allowed_to?(:view_news, project)
44 !user.nil? && user.allowed_to?(:view_news, project)
46 end
45 end
47
46
48 # Returns true if the news can be commented by user
47 # Returns true if the news can be commented by user
49 def commentable?(user=User.current)
48 def commentable?(user=User.current)
50 user.allowed_to?(:comment_news, project)
49 user.allowed_to?(:comment_news, project)
51 end
50 end
52
51
53 # returns latest news for projects visible by user
52 # returns latest news for projects visible by user
54 def self.latest(user = User.current, count = 5)
53 def self.latest(user = User.current, count = 5)
55 visible(user).includes([:author, :project]).order("#{News.table_name}.created_on DESC").limit(count).all
54 visible(user).includes([:author, :project]).order("#{News.table_name}.created_on DESC").limit(count).all
56 end
55 end
57
56
58 private
57 private
59
58
60 def add_author_as_watcher
59 def add_author_as_watcher
61 Watcher.create(:watchable => self, :user => author)
60 Watcher.create(:watchable => self, :user => author)
62 end
61 end
63 end
62 end
@@ -1,96 +1,96
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 self.table_name = "#{table_name_prefix}users#{table_name_suffix}"
19 self.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_ARCHIVED}", :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_ARCHIVED}", :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 scope :active, lambda { { :conditions => "#{Principal.table_name}.status = 1" } }
27 scope :active, lambda { where("#{Principal.table_name}.status = 1") }
28
28
29 scope :like, lambda {|q|
29 scope :like, lambda {|q|
30 q = q.to_s
30 q = q.to_s
31 if q.blank?
31 if q.blank?
32 where({})
32 where({})
33 else
33 else
34 pattern = "%#{q}%"
34 pattern = "%#{q}%"
35 sql = %w(login firstname lastname mail).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
35 sql = %w(login firstname lastname mail).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
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(#{table_name}.firstname) LIKE LOWER(:a) AND LOWER(#{table_name}.lastname) LIKE LOWER(:b))"
39 sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:a) AND LOWER(#{table_name}.lastname) LIKE LOWER(:b))"
40 sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:b) AND LOWER(#{table_name}.lastname) LIKE LOWER(:a))"
40 sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:b) AND LOWER(#{table_name}.lastname) LIKE LOWER(:a))"
41 params.merge!(:a => a, :b => b)
41 params.merge!(:a => a, :b => b)
42 end
42 end
43 where(sql, params)
43 where(sql, params)
44 end
44 end
45 }
45 }
46
46
47 # Principals that are members of a collection of projects
47 # Principals that are members of a collection of projects
48 scope :member_of, lambda {|projects|
48 scope :member_of, lambda {|projects|
49 projects = [projects] unless projects.is_a?(Array)
49 projects = [projects] unless projects.is_a?(Array)
50 if projects.empty?
50 if projects.empty?
51 where("1=0")
51 where("1=0")
52 else
52 else
53 ids = projects.map(&:id)
53 ids = projects.map(&:id)
54 where("#{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 where("#{Principal.table_name}.status = 1 AND #{Principal.table_name}.id IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
55 end
55 end
56 }
56 }
57 # Principals that are not members of projects
57 # Principals that are not members of projects
58 scope :not_member_of, lambda {|projects|
58 scope :not_member_of, lambda {|projects|
59 projects = [projects] unless projects.is_a?(Array)
59 projects = [projects] unless projects.is_a?(Array)
60 if projects.empty?
60 if projects.empty?
61 where("1=0")
61 where("1=0")
62 else
62 else
63 ids = projects.map(&:id)
63 ids = projects.map(&:id)
64 where("#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
64 where("#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
65 end
65 end
66 }
66 }
67
67
68 before_create :set_default_empty_values
68 before_create :set_default_empty_values
69
69
70 def name(formatter = nil)
70 def name(formatter = nil)
71 to_s
71 to_s
72 end
72 end
73
73
74 def <=>(principal)
74 def <=>(principal)
75 if principal.nil?
75 if principal.nil?
76 -1
76 -1
77 elsif self.class.name == principal.class.name
77 elsif self.class.name == principal.class.name
78 self.to_s.downcase <=> principal.to_s.downcase
78 self.to_s.downcase <=> principal.to_s.downcase
79 else
79 else
80 # groups after users
80 # groups after users
81 principal.class.name <=> self.class.name
81 principal.class.name <=> self.class.name
82 end
82 end
83 end
83 end
84
84
85 protected
85 protected
86
86
87 # Make sure we don't try to insert NULL values (see #4632)
87 # Make sure we don't try to insert NULL values (see #4632)
88 def set_default_empty_values
88 def set_default_empty_values
89 self.login ||= ''
89 self.login ||= ''
90 self.hashed_password ||= ''
90 self.hashed_password ||= ''
91 self.firstname ||= ''
91 self.firstname ||= ''
92 self.lastname ||= ''
92 self.lastname ||= ''
93 self.mail ||= ''
93 self.mail ||= ''
94 true
94 true
95 end
95 end
96 end
96 end
@@ -1,967 +1,969
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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_CLOSED = 5
23 STATUS_CLOSED = 5
24 STATUS_ARCHIVED = 9
24 STATUS_ARCHIVED = 9
25
25
26 # Maximum length for project identifiers
26 # Maximum length for project identifiers
27 IDENTIFIER_MAX_LENGTH = 100
27 IDENTIFIER_MAX_LENGTH = 100
28
28
29 # Specific overidden Activities
29 # Specific overidden Activities
30 has_many :time_entry_activities
30 has_many :time_entry_activities
31 has_many :members, :include => [:principal, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
31 has_many :members, :include => [:principal, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
32 has_many :memberships, :class_name => 'Member'
32 has_many :memberships, :class_name => 'Member'
33 has_many :member_principals, :class_name => 'Member',
33 has_many :member_principals, :class_name => 'Member',
34 :include => :principal,
34 :include => :principal,
35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
36 has_many :users, :through => :members
36 has_many :users, :through => :members
37 has_many :principals, :through => :member_principals, :source => :principal
37 has_many :principals, :through => :member_principals, :source => :principal
38
38
39 has_many :enabled_modules, :dependent => :delete_all
39 has_many :enabled_modules, :dependent => :delete_all
40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
42 has_many :issue_changes, :through => :issues, :source => :journals
42 has_many :issue_changes, :through => :issues, :source => :journals
43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
44 has_many :time_entries, :dependent => :delete_all
44 has_many :time_entries, :dependent => :delete_all
45 has_many :queries, :dependent => :delete_all
45 has_many :queries, :dependent => :delete_all
46 has_many :documents, :dependent => :destroy
46 has_many :documents, :dependent => :destroy
47 has_many :news, :dependent => :destroy, :include => :author
47 has_many :news, :dependent => :destroy, :include => :author
48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
49 has_many :boards, :dependent => :destroy, :order => "position ASC"
49 has_many :boards, :dependent => :destroy, :order => "position ASC"
50 has_one :repository, :conditions => ["is_default = ?", true]
50 has_one :repository, :conditions => ["is_default = ?", true]
51 has_many :repositories, :dependent => :destroy
51 has_many :repositories, :dependent => :destroy
52 has_many :changesets, :through => :repository
52 has_many :changesets, :through => :repository
53 has_one :wiki, :dependent => :destroy
53 has_one :wiki, :dependent => :destroy
54 # Custom field for the project issues
54 # Custom field for the project issues
55 has_and_belongs_to_many :issue_custom_fields,
55 has_and_belongs_to_many :issue_custom_fields,
56 :class_name => 'IssueCustomField',
56 :class_name => 'IssueCustomField',
57 :order => "#{CustomField.table_name}.position",
57 :order => "#{CustomField.table_name}.position",
58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
59 :association_foreign_key => 'custom_field_id'
59 :association_foreign_key => 'custom_field_id'
60
60
61 acts_as_nested_set :order => 'name', :dependent => :destroy
61 acts_as_nested_set :order => 'name', :dependent => :destroy
62 acts_as_attachable :view_permission => :view_files,
62 acts_as_attachable :view_permission => :view_files,
63 :delete_permission => :manage_files
63 :delete_permission => :manage_files
64
64
65 acts_as_customizable
65 acts_as_customizable
66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
69 :author => nil
69 :author => nil
70
70
71 attr_protected :status
71 attr_protected :status
72
72
73 validates_presence_of :name, :identifier
73 validates_presence_of :name, :identifier
74 validates_uniqueness_of :identifier
74 validates_uniqueness_of :identifier
75 validates_associated :repository, :wiki
75 validates_associated :repository, :wiki
76 validates_length_of :name, :maximum => 255
76 validates_length_of :name, :maximum => 255
77 validates_length_of :homepage, :maximum => 255
77 validates_length_of :homepage, :maximum => 255
78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
79 # donwcase letters, digits, dashes but not digits only
79 # donwcase letters, digits, dashes but not digits only
80 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
80 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
81 # reserved words
81 # reserved words
82 validates_exclusion_of :identifier, :in => %w( new )
82 validates_exclusion_of :identifier, :in => %w( new )
83
83
84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
85 before_destroy :delete_all_members
85 before_destroy :delete_all_members
86
86
87 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] } }
87 scope :has_module, lambda {|mod|
88 scope :active, lambda { { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}" } }
88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
89 }
90 scope :all_public, lambda { { :conditions => { :is_public => true } } }
90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 scope :all_public, lambda { where(:is_public => true) }
93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
92 scope :allowed_to, lambda {|*args|
94 scope :allowed_to, lambda {|*args|
93 user = User.current
95 user = User.current
94 permission = nil
96 permission = nil
95 if args.first.is_a?(Symbol)
97 if args.first.is_a?(Symbol)
96 permission = args.shift
98 permission = args.shift
97 else
99 else
98 user = args.shift
100 user = args.shift
99 permission = args.shift
101 permission = args.shift
100 end
102 end
101 { :conditions => Project.allowed_to_condition(user, permission, *args) }
103 where(Project.allowed_to_condition(user, permission, *args))
102 }
104 }
103 scope :like, lambda {|arg|
105 scope :like, lambda {|arg|
104 if arg.blank?
106 if arg.blank?
105 {}
107 where(nil)
106 else
108 else
107 pattern = "%#{arg.to_s.strip.downcase}%"
109 pattern = "%#{arg.to_s.strip.downcase}%"
108 {:conditions => ["LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", {:p => pattern}]}
110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
109 end
111 end
110 }
112 }
111
113
112 def initialize(attributes=nil, *args)
114 def initialize(attributes=nil, *args)
113 super
115 super
114
116
115 initialized = (attributes || {}).stringify_keys
117 initialized = (attributes || {}).stringify_keys
116 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
118 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
117 self.identifier = Project.next_identifier
119 self.identifier = Project.next_identifier
118 end
120 end
119 if !initialized.key?('is_public')
121 if !initialized.key?('is_public')
120 self.is_public = Setting.default_projects_public?
122 self.is_public = Setting.default_projects_public?
121 end
123 end
122 if !initialized.key?('enabled_module_names')
124 if !initialized.key?('enabled_module_names')
123 self.enabled_module_names = Setting.default_projects_modules
125 self.enabled_module_names = Setting.default_projects_modules
124 end
126 end
125 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
127 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
126 self.trackers = Tracker.sorted.all
128 self.trackers = Tracker.sorted.all
127 end
129 end
128 end
130 end
129
131
130 def identifier=(identifier)
132 def identifier=(identifier)
131 super unless identifier_frozen?
133 super unless identifier_frozen?
132 end
134 end
133
135
134 def identifier_frozen?
136 def identifier_frozen?
135 errors[:identifier].blank? && !(new_record? || identifier.blank?)
137 errors[:identifier].blank? && !(new_record? || identifier.blank?)
136 end
138 end
137
139
138 # returns latest created projects
140 # returns latest created projects
139 # non public projects will be returned only if user is a member of those
141 # non public projects will be returned only if user is a member of those
140 def self.latest(user=nil, count=5)
142 def self.latest(user=nil, count=5)
141 visible(user).limit(count).order("created_on DESC").all
143 visible(user).limit(count).order("created_on DESC").all
142 end
144 end
143
145
144 # Returns true if the project is visible to +user+ or to the current user.
146 # Returns true if the project is visible to +user+ or to the current user.
145 def visible?(user=User.current)
147 def visible?(user=User.current)
146 user.allowed_to?(:view_project, self)
148 user.allowed_to?(:view_project, self)
147 end
149 end
148
150
149 # Returns a SQL conditions string used to find all projects visible by the specified user.
151 # Returns a SQL conditions string used to find all projects visible by the specified user.
150 #
152 #
151 # Examples:
153 # Examples:
152 # Project.visible_condition(admin) => "projects.status = 1"
154 # Project.visible_condition(admin) => "projects.status = 1"
153 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
155 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
154 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
156 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
155 def self.visible_condition(user, options={})
157 def self.visible_condition(user, options={})
156 allowed_to_condition(user, :view_project, options)
158 allowed_to_condition(user, :view_project, options)
157 end
159 end
158
160
159 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
161 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
160 #
162 #
161 # Valid options:
163 # Valid options:
162 # * :project => limit the condition to project
164 # * :project => limit the condition to project
163 # * :with_subprojects => limit the condition to project and its subprojects
165 # * :with_subprojects => limit the condition to project and its subprojects
164 # * :member => limit the condition to the user projects
166 # * :member => limit the condition to the user projects
165 def self.allowed_to_condition(user, permission, options={})
167 def self.allowed_to_condition(user, permission, options={})
166 perm = Redmine::AccessControl.permission(permission)
168 perm = Redmine::AccessControl.permission(permission)
167 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
169 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
168 if perm && perm.project_module
170 if perm && perm.project_module
169 # If the permission belongs to a project module, make sure the module is enabled
171 # If the permission belongs to a project module, make sure the module is enabled
170 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
172 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
171 end
173 end
172 if options[:project]
174 if options[:project]
173 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
175 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
174 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
176 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
175 base_statement = "(#{project_statement}) AND (#{base_statement})"
177 base_statement = "(#{project_statement}) AND (#{base_statement})"
176 end
178 end
177
179
178 if user.admin?
180 if user.admin?
179 base_statement
181 base_statement
180 else
182 else
181 statement_by_role = {}
183 statement_by_role = {}
182 unless options[:member]
184 unless options[:member]
183 role = user.logged? ? Role.non_member : Role.anonymous
185 role = user.logged? ? Role.non_member : Role.anonymous
184 if role.allowed_to?(permission)
186 if role.allowed_to?(permission)
185 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
187 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
186 end
188 end
187 end
189 end
188 if user.logged?
190 if user.logged?
189 user.projects_by_role.each do |role, projects|
191 user.projects_by_role.each do |role, projects|
190 if role.allowed_to?(permission) && projects.any?
192 if role.allowed_to?(permission) && projects.any?
191 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
193 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
192 end
194 end
193 end
195 end
194 end
196 end
195 if statement_by_role.empty?
197 if statement_by_role.empty?
196 "1=0"
198 "1=0"
197 else
199 else
198 if block_given?
200 if block_given?
199 statement_by_role.each do |role, statement|
201 statement_by_role.each do |role, statement|
200 if s = yield(role, user)
202 if s = yield(role, user)
201 statement_by_role[role] = "(#{statement} AND (#{s}))"
203 statement_by_role[role] = "(#{statement} AND (#{s}))"
202 end
204 end
203 end
205 end
204 end
206 end
205 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
207 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
206 end
208 end
207 end
209 end
208 end
210 end
209
211
210 # Returns the Systemwide and project specific activities
212 # Returns the Systemwide and project specific activities
211 def activities(include_inactive=false)
213 def activities(include_inactive=false)
212 if include_inactive
214 if include_inactive
213 return all_activities
215 return all_activities
214 else
216 else
215 return active_activities
217 return active_activities
216 end
218 end
217 end
219 end
218
220
219 # Will create a new Project specific Activity or update an existing one
221 # Will create a new Project specific Activity or update an existing one
220 #
222 #
221 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
223 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
222 # does not successfully save.
224 # does not successfully save.
223 def update_or_create_time_entry_activity(id, activity_hash)
225 def update_or_create_time_entry_activity(id, activity_hash)
224 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
226 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
225 self.create_time_entry_activity_if_needed(activity_hash)
227 self.create_time_entry_activity_if_needed(activity_hash)
226 else
228 else
227 activity = project.time_entry_activities.find_by_id(id.to_i)
229 activity = project.time_entry_activities.find_by_id(id.to_i)
228 activity.update_attributes(activity_hash) if activity
230 activity.update_attributes(activity_hash) if activity
229 end
231 end
230 end
232 end
231
233
232 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
234 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
233 #
235 #
234 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
236 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
235 # does not successfully save.
237 # does not successfully save.
236 def create_time_entry_activity_if_needed(activity)
238 def create_time_entry_activity_if_needed(activity)
237 if activity['parent_id']
239 if activity['parent_id']
238
240
239 parent_activity = TimeEntryActivity.find(activity['parent_id'])
241 parent_activity = TimeEntryActivity.find(activity['parent_id'])
240 activity['name'] = parent_activity.name
242 activity['name'] = parent_activity.name
241 activity['position'] = parent_activity.position
243 activity['position'] = parent_activity.position
242
244
243 if Enumeration.overridding_change?(activity, parent_activity)
245 if Enumeration.overridding_change?(activity, parent_activity)
244 project_activity = self.time_entry_activities.create(activity)
246 project_activity = self.time_entry_activities.create(activity)
245
247
246 if project_activity.new_record?
248 if project_activity.new_record?
247 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
249 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
248 else
250 else
249 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
251 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
250 end
252 end
251 end
253 end
252 end
254 end
253 end
255 end
254
256
255 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
257 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
256 #
258 #
257 # Examples:
259 # Examples:
258 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
260 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
259 # project.project_condition(false) => "projects.id = 1"
261 # project.project_condition(false) => "projects.id = 1"
260 def project_condition(with_subprojects)
262 def project_condition(with_subprojects)
261 cond = "#{Project.table_name}.id = #{id}"
263 cond = "#{Project.table_name}.id = #{id}"
262 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
264 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
263 cond
265 cond
264 end
266 end
265
267
266 def self.find(*args)
268 def self.find(*args)
267 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
269 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
268 project = find_by_identifier(*args)
270 project = find_by_identifier(*args)
269 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
271 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
270 project
272 project
271 else
273 else
272 super
274 super
273 end
275 end
274 end
276 end
275
277
276 def self.find_by_param(*args)
278 def self.find_by_param(*args)
277 self.find(*args)
279 self.find(*args)
278 end
280 end
279
281
280 def reload(*args)
282 def reload(*args)
281 @shared_versions = nil
283 @shared_versions = nil
282 @rolled_up_versions = nil
284 @rolled_up_versions = nil
283 @rolled_up_trackers = nil
285 @rolled_up_trackers = nil
284 @all_issue_custom_fields = nil
286 @all_issue_custom_fields = nil
285 @all_time_entry_custom_fields = nil
287 @all_time_entry_custom_fields = nil
286 @to_param = nil
288 @to_param = nil
287 @allowed_parents = nil
289 @allowed_parents = nil
288 @allowed_permissions = nil
290 @allowed_permissions = nil
289 @actions_allowed = nil
291 @actions_allowed = nil
290 super
292 super
291 end
293 end
292
294
293 def to_param
295 def to_param
294 # id is used for projects with a numeric identifier (compatibility)
296 # id is used for projects with a numeric identifier (compatibility)
295 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
297 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
296 end
298 end
297
299
298 def active?
300 def active?
299 self.status == STATUS_ACTIVE
301 self.status == STATUS_ACTIVE
300 end
302 end
301
303
302 def archived?
304 def archived?
303 self.status == STATUS_ARCHIVED
305 self.status == STATUS_ARCHIVED
304 end
306 end
305
307
306 # Archives the project and its descendants
308 # Archives the project and its descendants
307 def archive
309 def archive
308 # Check that there is no issue of a non descendant project that is assigned
310 # Check that there is no issue of a non descendant project that is assigned
309 # to one of the project or descendant versions
311 # to one of the project or descendant versions
310 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
312 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
311 if v_ids.any? &&
313 if v_ids.any? &&
312 Issue.
314 Issue.
313 includes(:project).
315 includes(:project).
314 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
316 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
315 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
317 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
316 exists?
318 exists?
317 return false
319 return false
318 end
320 end
319 Project.transaction do
321 Project.transaction do
320 archive!
322 archive!
321 end
323 end
322 true
324 true
323 end
325 end
324
326
325 # Unarchives the project
327 # Unarchives the project
326 # All its ancestors must be active
328 # All its ancestors must be active
327 def unarchive
329 def unarchive
328 return false if ancestors.detect {|a| !a.active?}
330 return false if ancestors.detect {|a| !a.active?}
329 update_attribute :status, STATUS_ACTIVE
331 update_attribute :status, STATUS_ACTIVE
330 end
332 end
331
333
332 def close
334 def close
333 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
335 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
334 end
336 end
335
337
336 def reopen
338 def reopen
337 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
339 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
338 end
340 end
339
341
340 # Returns an array of projects the project can be moved to
342 # Returns an array of projects the project can be moved to
341 # by the current user
343 # by the current user
342 def allowed_parents
344 def allowed_parents
343 return @allowed_parents if @allowed_parents
345 return @allowed_parents if @allowed_parents
344 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
346 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
345 @allowed_parents = @allowed_parents - self_and_descendants
347 @allowed_parents = @allowed_parents - self_and_descendants
346 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
348 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
347 @allowed_parents << nil
349 @allowed_parents << nil
348 end
350 end
349 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
351 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
350 @allowed_parents << parent
352 @allowed_parents << parent
351 end
353 end
352 @allowed_parents
354 @allowed_parents
353 end
355 end
354
356
355 # Sets the parent of the project with authorization check
357 # Sets the parent of the project with authorization check
356 def set_allowed_parent!(p)
358 def set_allowed_parent!(p)
357 unless p.nil? || p.is_a?(Project)
359 unless p.nil? || p.is_a?(Project)
358 if p.to_s.blank?
360 if p.to_s.blank?
359 p = nil
361 p = nil
360 else
362 else
361 p = Project.find_by_id(p)
363 p = Project.find_by_id(p)
362 return false unless p
364 return false unless p
363 end
365 end
364 end
366 end
365 if p.nil?
367 if p.nil?
366 if !new_record? && allowed_parents.empty?
368 if !new_record? && allowed_parents.empty?
367 return false
369 return false
368 end
370 end
369 elsif !allowed_parents.include?(p)
371 elsif !allowed_parents.include?(p)
370 return false
372 return false
371 end
373 end
372 set_parent!(p)
374 set_parent!(p)
373 end
375 end
374
376
375 # Sets the parent of the project
377 # Sets the parent of the project
376 # Argument can be either a Project, a String, a Fixnum or nil
378 # Argument can be either a Project, a String, a Fixnum or nil
377 def set_parent!(p)
379 def set_parent!(p)
378 unless p.nil? || p.is_a?(Project)
380 unless p.nil? || p.is_a?(Project)
379 if p.to_s.blank?
381 if p.to_s.blank?
380 p = nil
382 p = nil
381 else
383 else
382 p = Project.find_by_id(p)
384 p = Project.find_by_id(p)
383 return false unless p
385 return false unless p
384 end
386 end
385 end
387 end
386 if p == parent && !p.nil?
388 if p == parent && !p.nil?
387 # Nothing to do
389 # Nothing to do
388 true
390 true
389 elsif p.nil? || (p.active? && move_possible?(p))
391 elsif p.nil? || (p.active? && move_possible?(p))
390 set_or_update_position_under(p)
392 set_or_update_position_under(p)
391 Issue.update_versions_from_hierarchy_change(self)
393 Issue.update_versions_from_hierarchy_change(self)
392 true
394 true
393 else
395 else
394 # Can not move to the given target
396 # Can not move to the given target
395 false
397 false
396 end
398 end
397 end
399 end
398
400
399 # Recalculates all lft and rgt values based on project names
401 # Recalculates all lft and rgt values based on project names
400 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
402 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
401 # Used in BuildProjectsTree migration
403 # Used in BuildProjectsTree migration
402 def self.rebuild_tree!
404 def self.rebuild_tree!
403 transaction do
405 transaction do
404 update_all "lft = NULL, rgt = NULL"
406 update_all "lft = NULL, rgt = NULL"
405 rebuild!(false)
407 rebuild!(false)
406 end
408 end
407 end
409 end
408
410
409 # Returns an array of the trackers used by the project and its active sub projects
411 # Returns an array of the trackers used by the project and its active sub projects
410 def rolled_up_trackers
412 def rolled_up_trackers
411 @rolled_up_trackers ||=
413 @rolled_up_trackers ||=
412 Tracker.
414 Tracker.
413 joins(:projects).
415 joins(:projects).
414 select("DISTINCT #{Tracker.table_name}.*").
416 select("DISTINCT #{Tracker.table_name}.*").
415 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
417 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
416 sorted.
418 sorted.
417 all
419 all
418 end
420 end
419
421
420 # Closes open and locked project versions that are completed
422 # Closes open and locked project versions that are completed
421 def close_completed_versions
423 def close_completed_versions
422 Version.transaction do
424 Version.transaction do
423 versions.where(:status => %w(open locked)).all.each do |version|
425 versions.where(:status => %w(open locked)).all.each do |version|
424 if version.completed?
426 if version.completed?
425 version.update_attribute(:status, 'closed')
427 version.update_attribute(:status, 'closed')
426 end
428 end
427 end
429 end
428 end
430 end
429 end
431 end
430
432
431 # Returns a scope of the Versions on subprojects
433 # Returns a scope of the Versions on subprojects
432 def rolled_up_versions
434 def rolled_up_versions
433 @rolled_up_versions ||=
435 @rolled_up_versions ||=
434 Version.scoped(:include => :project,
436 Version.scoped(:include => :project,
435 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
437 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
436 end
438 end
437
439
438 # Returns a scope of the Versions used by the project
440 # Returns a scope of the Versions used by the project
439 def shared_versions
441 def shared_versions
440 if new_record?
442 if new_record?
441 Version.scoped(:include => :project,
443 Version.scoped(:include => :project,
442 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
444 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
443 else
445 else
444 @shared_versions ||= begin
446 @shared_versions ||= begin
445 r = root? ? self : root
447 r = root? ? self : root
446 Version.scoped(:include => :project,
448 Version.scoped(:include => :project,
447 :conditions => "#{Project.table_name}.id = #{id}" +
449 :conditions => "#{Project.table_name}.id = #{id}" +
448 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
450 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
449 " #{Version.table_name}.sharing = 'system'" +
451 " #{Version.table_name}.sharing = 'system'" +
450 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
452 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
451 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
453 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
452 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
454 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
453 "))")
455 "))")
454 end
456 end
455 end
457 end
456 end
458 end
457
459
458 # Returns a hash of project users grouped by role
460 # Returns a hash of project users grouped by role
459 def users_by_role
461 def users_by_role
460 members.includes(:user, :roles).all.inject({}) do |h, m|
462 members.includes(:user, :roles).all.inject({}) do |h, m|
461 m.roles.each do |r|
463 m.roles.each do |r|
462 h[r] ||= []
464 h[r] ||= []
463 h[r] << m.user
465 h[r] << m.user
464 end
466 end
465 h
467 h
466 end
468 end
467 end
469 end
468
470
469 # Deletes all project's members
471 # Deletes all project's members
470 def delete_all_members
472 def delete_all_members
471 me, mr = Member.table_name, MemberRole.table_name
473 me, mr = Member.table_name, MemberRole.table_name
472 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
474 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
473 Member.delete_all(['project_id = ?', id])
475 Member.delete_all(['project_id = ?', id])
474 end
476 end
475
477
476 # Users/groups issues can be assigned to
478 # Users/groups issues can be assigned to
477 def assignable_users
479 def assignable_users
478 assignable = Setting.issue_group_assignment? ? member_principals : members
480 assignable = Setting.issue_group_assignment? ? member_principals : members
479 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
481 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
480 end
482 end
481
483
482 # Returns the mail adresses of users that should be always notified on project events
484 # Returns the mail adresses of users that should be always notified on project events
483 def recipients
485 def recipients
484 notified_users.collect {|user| user.mail}
486 notified_users.collect {|user| user.mail}
485 end
487 end
486
488
487 # Returns the users that should be notified on project events
489 # Returns the users that should be notified on project events
488 def notified_users
490 def notified_users
489 # TODO: User part should be extracted to User#notify_about?
491 # TODO: User part should be extracted to User#notify_about?
490 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
492 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
491 end
493 end
492
494
493 # Returns an array of all custom fields enabled for project issues
495 # Returns an array of all custom fields enabled for project issues
494 # (explictly associated custom fields and custom fields enabled for all projects)
496 # (explictly associated custom fields and custom fields enabled for all projects)
495 def all_issue_custom_fields
497 def all_issue_custom_fields
496 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
498 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
497 end
499 end
498
500
499 # Returns an array of all custom fields enabled for project time entries
501 # Returns an array of all custom fields enabled for project time entries
500 # (explictly associated custom fields and custom fields enabled for all projects)
502 # (explictly associated custom fields and custom fields enabled for all projects)
501 def all_time_entry_custom_fields
503 def all_time_entry_custom_fields
502 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
504 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
503 end
505 end
504
506
505 def project
507 def project
506 self
508 self
507 end
509 end
508
510
509 def <=>(project)
511 def <=>(project)
510 name.downcase <=> project.name.downcase
512 name.downcase <=> project.name.downcase
511 end
513 end
512
514
513 def to_s
515 def to_s
514 name
516 name
515 end
517 end
516
518
517 # Returns a short description of the projects (first lines)
519 # Returns a short description of the projects (first lines)
518 def short_description(length = 255)
520 def short_description(length = 255)
519 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
521 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
520 end
522 end
521
523
522 def css_classes
524 def css_classes
523 s = 'project'
525 s = 'project'
524 s << ' root' if root?
526 s << ' root' if root?
525 s << ' child' if child?
527 s << ' child' if child?
526 s << (leaf? ? ' leaf' : ' parent')
528 s << (leaf? ? ' leaf' : ' parent')
527 unless active?
529 unless active?
528 if archived?
530 if archived?
529 s << ' archived'
531 s << ' archived'
530 else
532 else
531 s << ' closed'
533 s << ' closed'
532 end
534 end
533 end
535 end
534 s
536 s
535 end
537 end
536
538
537 # The earliest start date of a project, based on it's issues and versions
539 # The earliest start date of a project, based on it's issues and versions
538 def start_date
540 def start_date
539 [
541 [
540 issues.minimum('start_date'),
542 issues.minimum('start_date'),
541 shared_versions.collect(&:effective_date),
543 shared_versions.collect(&:effective_date),
542 shared_versions.collect(&:start_date)
544 shared_versions.collect(&:start_date)
543 ].flatten.compact.min
545 ].flatten.compact.min
544 end
546 end
545
547
546 # The latest due date of an issue or version
548 # The latest due date of an issue or version
547 def due_date
549 def due_date
548 [
550 [
549 issues.maximum('due_date'),
551 issues.maximum('due_date'),
550 shared_versions.collect(&:effective_date),
552 shared_versions.collect(&:effective_date),
551 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
553 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
552 ].flatten.compact.max
554 ].flatten.compact.max
553 end
555 end
554
556
555 def overdue?
557 def overdue?
556 active? && !due_date.nil? && (due_date < Date.today)
558 active? && !due_date.nil? && (due_date < Date.today)
557 end
559 end
558
560
559 # Returns the percent completed for this project, based on the
561 # Returns the percent completed for this project, based on the
560 # progress on it's versions.
562 # progress on it's versions.
561 def completed_percent(options={:include_subprojects => false})
563 def completed_percent(options={:include_subprojects => false})
562 if options.delete(:include_subprojects)
564 if options.delete(:include_subprojects)
563 total = self_and_descendants.collect(&:completed_percent).sum
565 total = self_and_descendants.collect(&:completed_percent).sum
564
566
565 total / self_and_descendants.count
567 total / self_and_descendants.count
566 else
568 else
567 if versions.count > 0
569 if versions.count > 0
568 total = versions.collect(&:completed_pourcent).sum
570 total = versions.collect(&:completed_pourcent).sum
569
571
570 total / versions.count
572 total / versions.count
571 else
573 else
572 100
574 100
573 end
575 end
574 end
576 end
575 end
577 end
576
578
577 # Return true if this project allows to do the specified action.
579 # Return true if this project allows to do the specified action.
578 # action can be:
580 # action can be:
579 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
581 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
580 # * a permission Symbol (eg. :edit_project)
582 # * a permission Symbol (eg. :edit_project)
581 def allows_to?(action)
583 def allows_to?(action)
582 if archived?
584 if archived?
583 # No action allowed on archived projects
585 # No action allowed on archived projects
584 return false
586 return false
585 end
587 end
586 unless active? || Redmine::AccessControl.read_action?(action)
588 unless active? || Redmine::AccessControl.read_action?(action)
587 # No write action allowed on closed projects
589 # No write action allowed on closed projects
588 return false
590 return false
589 end
591 end
590 # No action allowed on disabled modules
592 # No action allowed on disabled modules
591 if action.is_a? Hash
593 if action.is_a? Hash
592 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
594 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
593 else
595 else
594 allowed_permissions.include? action
596 allowed_permissions.include? action
595 end
597 end
596 end
598 end
597
599
598 def module_enabled?(module_name)
600 def module_enabled?(module_name)
599 module_name = module_name.to_s
601 module_name = module_name.to_s
600 enabled_modules.detect {|m| m.name == module_name}
602 enabled_modules.detect {|m| m.name == module_name}
601 end
603 end
602
604
603 def enabled_module_names=(module_names)
605 def enabled_module_names=(module_names)
604 if module_names && module_names.is_a?(Array)
606 if module_names && module_names.is_a?(Array)
605 module_names = module_names.collect(&:to_s).reject(&:blank?)
607 module_names = module_names.collect(&:to_s).reject(&:blank?)
606 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
608 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
607 else
609 else
608 enabled_modules.clear
610 enabled_modules.clear
609 end
611 end
610 end
612 end
611
613
612 # Returns an array of the enabled modules names
614 # Returns an array of the enabled modules names
613 def enabled_module_names
615 def enabled_module_names
614 enabled_modules.collect(&:name)
616 enabled_modules.collect(&:name)
615 end
617 end
616
618
617 # Enable a specific module
619 # Enable a specific module
618 #
620 #
619 # Examples:
621 # Examples:
620 # project.enable_module!(:issue_tracking)
622 # project.enable_module!(:issue_tracking)
621 # project.enable_module!("issue_tracking")
623 # project.enable_module!("issue_tracking")
622 def enable_module!(name)
624 def enable_module!(name)
623 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
625 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
624 end
626 end
625
627
626 # Disable a module if it exists
628 # Disable a module if it exists
627 #
629 #
628 # Examples:
630 # Examples:
629 # project.disable_module!(:issue_tracking)
631 # project.disable_module!(:issue_tracking)
630 # project.disable_module!("issue_tracking")
632 # project.disable_module!("issue_tracking")
631 # project.disable_module!(project.enabled_modules.first)
633 # project.disable_module!(project.enabled_modules.first)
632 def disable_module!(target)
634 def disable_module!(target)
633 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
635 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
634 target.destroy unless target.blank?
636 target.destroy unless target.blank?
635 end
637 end
636
638
637 safe_attributes 'name',
639 safe_attributes 'name',
638 'description',
640 'description',
639 'homepage',
641 'homepage',
640 'is_public',
642 'is_public',
641 'identifier',
643 'identifier',
642 'custom_field_values',
644 'custom_field_values',
643 'custom_fields',
645 'custom_fields',
644 'tracker_ids',
646 'tracker_ids',
645 'issue_custom_field_ids'
647 'issue_custom_field_ids'
646
648
647 safe_attributes 'enabled_module_names',
649 safe_attributes 'enabled_module_names',
648 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
650 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
649
651
650 # Returns an array of projects that are in this project's hierarchy
652 # Returns an array of projects that are in this project's hierarchy
651 #
653 #
652 # Example: parents, children, siblings
654 # Example: parents, children, siblings
653 def hierarchy
655 def hierarchy
654 parents = project.self_and_ancestors || []
656 parents = project.self_and_ancestors || []
655 descendants = project.descendants || []
657 descendants = project.descendants || []
656 project_hierarchy = parents | descendants # Set union
658 project_hierarchy = parents | descendants # Set union
657 end
659 end
658
660
659 # Returns an auto-generated project identifier based on the last identifier used
661 # Returns an auto-generated project identifier based on the last identifier used
660 def self.next_identifier
662 def self.next_identifier
661 p = Project.order('created_on DESC').first
663 p = Project.order('created_on DESC').first
662 p.nil? ? nil : p.identifier.to_s.succ
664 p.nil? ? nil : p.identifier.to_s.succ
663 end
665 end
664
666
665 # Copies and saves the Project instance based on the +project+.
667 # Copies and saves the Project instance based on the +project+.
666 # Duplicates the source project's:
668 # Duplicates the source project's:
667 # * Wiki
669 # * Wiki
668 # * Versions
670 # * Versions
669 # * Categories
671 # * Categories
670 # * Issues
672 # * Issues
671 # * Members
673 # * Members
672 # * Queries
674 # * Queries
673 #
675 #
674 # Accepts an +options+ argument to specify what to copy
676 # Accepts an +options+ argument to specify what to copy
675 #
677 #
676 # Examples:
678 # Examples:
677 # project.copy(1) # => copies everything
679 # project.copy(1) # => copies everything
678 # project.copy(1, :only => 'members') # => copies members only
680 # project.copy(1, :only => 'members') # => copies members only
679 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
681 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
680 def copy(project, options={})
682 def copy(project, options={})
681 project = project.is_a?(Project) ? project : Project.find(project)
683 project = project.is_a?(Project) ? project : Project.find(project)
682
684
683 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
685 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
684 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
686 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
685
687
686 Project.transaction do
688 Project.transaction do
687 if save
689 if save
688 reload
690 reload
689 to_be_copied.each do |name|
691 to_be_copied.each do |name|
690 send "copy_#{name}", project
692 send "copy_#{name}", project
691 end
693 end
692 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
694 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
693 save
695 save
694 end
696 end
695 end
697 end
696 end
698 end
697
699
698 # Returns a new unsaved Project instance with attributes copied from +project+
700 # Returns a new unsaved Project instance with attributes copied from +project+
699 def self.copy_from(project)
701 def self.copy_from(project)
700 project = project.is_a?(Project) ? project : Project.find(project)
702 project = project.is_a?(Project) ? project : Project.find(project)
701 # clear unique attributes
703 # clear unique attributes
702 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
704 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
703 copy = Project.new(attributes)
705 copy = Project.new(attributes)
704 copy.enabled_modules = project.enabled_modules
706 copy.enabled_modules = project.enabled_modules
705 copy.trackers = project.trackers
707 copy.trackers = project.trackers
706 copy.custom_values = project.custom_values.collect {|v| v.clone}
708 copy.custom_values = project.custom_values.collect {|v| v.clone}
707 copy.issue_custom_fields = project.issue_custom_fields
709 copy.issue_custom_fields = project.issue_custom_fields
708 copy
710 copy
709 end
711 end
710
712
711 # Yields the given block for each project with its level in the tree
713 # Yields the given block for each project with its level in the tree
712 def self.project_tree(projects, &block)
714 def self.project_tree(projects, &block)
713 ancestors = []
715 ancestors = []
714 projects.sort_by(&:lft).each do |project|
716 projects.sort_by(&:lft).each do |project|
715 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
717 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
716 ancestors.pop
718 ancestors.pop
717 end
719 end
718 yield project, ancestors.size
720 yield project, ancestors.size
719 ancestors << project
721 ancestors << project
720 end
722 end
721 end
723 end
722
724
723 private
725 private
724
726
725 # Copies wiki from +project+
727 # Copies wiki from +project+
726 def copy_wiki(project)
728 def copy_wiki(project)
727 # Check that the source project has a wiki first
729 # Check that the source project has a wiki first
728 unless project.wiki.nil?
730 unless project.wiki.nil?
729 self.wiki ||= Wiki.new
731 self.wiki ||= Wiki.new
730 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
732 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
731 wiki_pages_map = {}
733 wiki_pages_map = {}
732 project.wiki.pages.each do |page|
734 project.wiki.pages.each do |page|
733 # Skip pages without content
735 # Skip pages without content
734 next if page.content.nil?
736 next if page.content.nil?
735 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
737 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
736 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
738 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
737 new_wiki_page.content = new_wiki_content
739 new_wiki_page.content = new_wiki_content
738 wiki.pages << new_wiki_page
740 wiki.pages << new_wiki_page
739 wiki_pages_map[page.id] = new_wiki_page
741 wiki_pages_map[page.id] = new_wiki_page
740 end
742 end
741 wiki.save
743 wiki.save
742 # Reproduce page hierarchy
744 # Reproduce page hierarchy
743 project.wiki.pages.each do |page|
745 project.wiki.pages.each do |page|
744 if page.parent_id && wiki_pages_map[page.id]
746 if page.parent_id && wiki_pages_map[page.id]
745 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
747 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
746 wiki_pages_map[page.id].save
748 wiki_pages_map[page.id].save
747 end
749 end
748 end
750 end
749 end
751 end
750 end
752 end
751
753
752 # Copies versions from +project+
754 # Copies versions from +project+
753 def copy_versions(project)
755 def copy_versions(project)
754 project.versions.each do |version|
756 project.versions.each do |version|
755 new_version = Version.new
757 new_version = Version.new
756 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
758 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
757 self.versions << new_version
759 self.versions << new_version
758 end
760 end
759 end
761 end
760
762
761 # Copies issue categories from +project+
763 # Copies issue categories from +project+
762 def copy_issue_categories(project)
764 def copy_issue_categories(project)
763 project.issue_categories.each do |issue_category|
765 project.issue_categories.each do |issue_category|
764 new_issue_category = IssueCategory.new
766 new_issue_category = IssueCategory.new
765 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
767 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
766 self.issue_categories << new_issue_category
768 self.issue_categories << new_issue_category
767 end
769 end
768 end
770 end
769
771
770 # Copies issues from +project+
772 # Copies issues from +project+
771 def copy_issues(project)
773 def copy_issues(project)
772 # Stores the source issue id as a key and the copied issues as the
774 # Stores the source issue id as a key and the copied issues as the
773 # value. Used to map the two togeather for issue relations.
775 # value. Used to map the two togeather for issue relations.
774 issues_map = {}
776 issues_map = {}
775
777
776 # Store status and reopen locked/closed versions
778 # Store status and reopen locked/closed versions
777 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
779 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
778 version_statuses.each do |version, status|
780 version_statuses.each do |version, status|
779 version.update_attribute :status, 'open'
781 version.update_attribute :status, 'open'
780 end
782 end
781
783
782 # Get issues sorted by root_id, lft so that parent issues
784 # Get issues sorted by root_id, lft so that parent issues
783 # get copied before their children
785 # get copied before their children
784 project.issues.reorder('root_id, lft').all.each do |issue|
786 project.issues.reorder('root_id, lft').all.each do |issue|
785 new_issue = Issue.new
787 new_issue = Issue.new
786 new_issue.copy_from(issue, :subtasks => false, :link => false)
788 new_issue.copy_from(issue, :subtasks => false, :link => false)
787 new_issue.project = self
789 new_issue.project = self
788 # Reassign fixed_versions by name, since names are unique per project
790 # Reassign fixed_versions by name, since names are unique per project
789 if issue.fixed_version && issue.fixed_version.project == project
791 if issue.fixed_version && issue.fixed_version.project == project
790 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
792 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
791 end
793 end
792 # Reassign the category by name, since names are unique per project
794 # Reassign the category by name, since names are unique per project
793 if issue.category
795 if issue.category
794 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
796 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
795 end
797 end
796 # Parent issue
798 # Parent issue
797 if issue.parent_id
799 if issue.parent_id
798 if copied_parent = issues_map[issue.parent_id]
800 if copied_parent = issues_map[issue.parent_id]
799 new_issue.parent_issue_id = copied_parent.id
801 new_issue.parent_issue_id = copied_parent.id
800 end
802 end
801 end
803 end
802
804
803 self.issues << new_issue
805 self.issues << new_issue
804 if new_issue.new_record?
806 if new_issue.new_record?
805 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
807 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
806 else
808 else
807 issues_map[issue.id] = new_issue unless new_issue.new_record?
809 issues_map[issue.id] = new_issue unless new_issue.new_record?
808 end
810 end
809 end
811 end
810
812
811 # Restore locked/closed version statuses
813 # Restore locked/closed version statuses
812 version_statuses.each do |version, status|
814 version_statuses.each do |version, status|
813 version.update_attribute :status, status
815 version.update_attribute :status, status
814 end
816 end
815
817
816 # Relations after in case issues related each other
818 # Relations after in case issues related each other
817 project.issues.each do |issue|
819 project.issues.each do |issue|
818 new_issue = issues_map[issue.id]
820 new_issue = issues_map[issue.id]
819 unless new_issue
821 unless new_issue
820 # Issue was not copied
822 # Issue was not copied
821 next
823 next
822 end
824 end
823
825
824 # Relations
826 # Relations
825 issue.relations_from.each do |source_relation|
827 issue.relations_from.each do |source_relation|
826 new_issue_relation = IssueRelation.new
828 new_issue_relation = IssueRelation.new
827 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
829 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
828 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
830 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
829 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
831 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
830 new_issue_relation.issue_to = source_relation.issue_to
832 new_issue_relation.issue_to = source_relation.issue_to
831 end
833 end
832 new_issue.relations_from << new_issue_relation
834 new_issue.relations_from << new_issue_relation
833 end
835 end
834
836
835 issue.relations_to.each do |source_relation|
837 issue.relations_to.each do |source_relation|
836 new_issue_relation = IssueRelation.new
838 new_issue_relation = IssueRelation.new
837 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
839 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
838 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
840 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
839 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
841 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
840 new_issue_relation.issue_from = source_relation.issue_from
842 new_issue_relation.issue_from = source_relation.issue_from
841 end
843 end
842 new_issue.relations_to << new_issue_relation
844 new_issue.relations_to << new_issue_relation
843 end
845 end
844 end
846 end
845 end
847 end
846
848
847 # Copies members from +project+
849 # Copies members from +project+
848 def copy_members(project)
850 def copy_members(project)
849 # Copy users first, then groups to handle members with inherited and given roles
851 # Copy users first, then groups to handle members with inherited and given roles
850 members_to_copy = []
852 members_to_copy = []
851 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
853 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
852 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
854 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
853
855
854 members_to_copy.each do |member|
856 members_to_copy.each do |member|
855 new_member = Member.new
857 new_member = Member.new
856 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
858 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
857 # only copy non inherited roles
859 # only copy non inherited roles
858 # inherited roles will be added when copying the group membership
860 # inherited roles will be added when copying the group membership
859 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
861 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
860 next if role_ids.empty?
862 next if role_ids.empty?
861 new_member.role_ids = role_ids
863 new_member.role_ids = role_ids
862 new_member.project = self
864 new_member.project = self
863 self.members << new_member
865 self.members << new_member
864 end
866 end
865 end
867 end
866
868
867 # Copies queries from +project+
869 # Copies queries from +project+
868 def copy_queries(project)
870 def copy_queries(project)
869 project.queries.each do |query|
871 project.queries.each do |query|
870 new_query = ::Query.new
872 new_query = ::Query.new
871 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
873 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
872 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
874 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
873 new_query.project = self
875 new_query.project = self
874 new_query.user_id = query.user_id
876 new_query.user_id = query.user_id
875 self.queries << new_query
877 self.queries << new_query
876 end
878 end
877 end
879 end
878
880
879 # Copies boards from +project+
881 # Copies boards from +project+
880 def copy_boards(project)
882 def copy_boards(project)
881 project.boards.each do |board|
883 project.boards.each do |board|
882 new_board = Board.new
884 new_board = Board.new
883 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
885 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
884 new_board.project = self
886 new_board.project = self
885 self.boards << new_board
887 self.boards << new_board
886 end
888 end
887 end
889 end
888
890
889 def allowed_permissions
891 def allowed_permissions
890 @allowed_permissions ||= begin
892 @allowed_permissions ||= begin
891 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
893 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
892 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
894 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
893 end
895 end
894 end
896 end
895
897
896 def allowed_actions
898 def allowed_actions
897 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
899 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
898 end
900 end
899
901
900 # Returns all the active Systemwide and project specific activities
902 # Returns all the active Systemwide and project specific activities
901 def active_activities
903 def active_activities
902 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
904 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
903
905
904 if overridden_activity_ids.empty?
906 if overridden_activity_ids.empty?
905 return TimeEntryActivity.shared.active
907 return TimeEntryActivity.shared.active
906 else
908 else
907 return system_activities_and_project_overrides
909 return system_activities_and_project_overrides
908 end
910 end
909 end
911 end
910
912
911 # Returns all the Systemwide and project specific activities
913 # Returns all the Systemwide and project specific activities
912 # (inactive and active)
914 # (inactive and active)
913 def all_activities
915 def all_activities
914 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
916 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
915
917
916 if overridden_activity_ids.empty?
918 if overridden_activity_ids.empty?
917 return TimeEntryActivity.shared
919 return TimeEntryActivity.shared
918 else
920 else
919 return system_activities_and_project_overrides(true)
921 return system_activities_and_project_overrides(true)
920 end
922 end
921 end
923 end
922
924
923 # Returns the systemwide active activities merged with the project specific overrides
925 # Returns the systemwide active activities merged with the project specific overrides
924 def system_activities_and_project_overrides(include_inactive=false)
926 def system_activities_and_project_overrides(include_inactive=false)
925 if include_inactive
927 if include_inactive
926 return TimeEntryActivity.shared.
928 return TimeEntryActivity.shared.
927 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
929 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
928 self.time_entry_activities
930 self.time_entry_activities
929 else
931 else
930 return TimeEntryActivity.shared.active.
932 return TimeEntryActivity.shared.active.
931 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
933 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
932 self.time_entry_activities.active
934 self.time_entry_activities.active
933 end
935 end
934 end
936 end
935
937
936 # Archives subprojects recursively
938 # Archives subprojects recursively
937 def archive!
939 def archive!
938 children.each do |subproject|
940 children.each do |subproject|
939 subproject.send :archive!
941 subproject.send :archive!
940 end
942 end
941 update_attribute :status, STATUS_ARCHIVED
943 update_attribute :status, STATUS_ARCHIVED
942 end
944 end
943
945
944 def update_position_under_parent
946 def update_position_under_parent
945 set_or_update_position_under(parent)
947 set_or_update_position_under(parent)
946 end
948 end
947
949
948 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
950 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
949 def set_or_update_position_under(target_parent)
951 def set_or_update_position_under(target_parent)
950 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
952 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
951 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
953 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
952
954
953 if to_be_inserted_before
955 if to_be_inserted_before
954 move_to_left_of(to_be_inserted_before)
956 move_to_left_of(to_be_inserted_before)
955 elsif target_parent.nil?
957 elsif target_parent.nil?
956 if sibs.empty?
958 if sibs.empty?
957 # move_to_root adds the project in first (ie. left) position
959 # move_to_root adds the project in first (ie. left) position
958 move_to_root
960 move_to_root
959 else
961 else
960 move_to_right_of(sibs.last) unless self == sibs.last
962 move_to_right_of(sibs.last) unless self == sibs.last
961 end
963 end
962 else
964 else
963 # move_to_child_of adds the project in last (ie.right) position
965 # move_to_child_of adds the project in last (ie.right) position
964 move_to_child_of(target_parent)
966 move_to_child_of(target_parent)
965 end
967 end
966 end
968 end
967 end
969 end
@@ -1,1109 +1,1107
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 @inline = options.key?(:inline) ? options[:inline] : true
30 @inline = options.key?(:inline) ? options[:inline] : true
31 @caption_key = options[:caption] || "field_#{name}"
31 @caption_key = options[:caption] || "field_#{name}"
32 end
32 end
33
33
34 def caption
34 def caption
35 l(@caption_key)
35 l(@caption_key)
36 end
36 end
37
37
38 # Returns true if the column is sortable, otherwise false
38 # Returns true if the column is sortable, otherwise false
39 def sortable?
39 def sortable?
40 !@sortable.nil?
40 !@sortable.nil?
41 end
41 end
42
42
43 def sortable
43 def sortable
44 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 @sortable.is_a?(Proc) ? @sortable.call : @sortable
45 end
45 end
46
46
47 def inline?
47 def inline?
48 @inline
48 @inline
49 end
49 end
50
50
51 def value(issue)
51 def value(issue)
52 issue.send name
52 issue.send name
53 end
53 end
54
54
55 def css_classes
55 def css_classes
56 name
56 name
57 end
57 end
58 end
58 end
59
59
60 class QueryCustomFieldColumn < QueryColumn
60 class QueryCustomFieldColumn < QueryColumn
61
61
62 def initialize(custom_field)
62 def initialize(custom_field)
63 self.name = "cf_#{custom_field.id}".to_sym
63 self.name = "cf_#{custom_field.id}".to_sym
64 self.sortable = custom_field.order_statement || false
64 self.sortable = custom_field.order_statement || false
65 self.groupable = custom_field.group_statement || false
65 self.groupable = custom_field.group_statement || false
66 @inline = true
66 @inline = true
67 @cf = custom_field
67 @cf = custom_field
68 end
68 end
69
69
70 def caption
70 def caption
71 @cf.name
71 @cf.name
72 end
72 end
73
73
74 def custom_field
74 def custom_field
75 @cf
75 @cf
76 end
76 end
77
77
78 def value(issue)
78 def value(issue)
79 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
79 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
80 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
80 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
81 end
81 end
82
82
83 def css_classes
83 def css_classes
84 @css_classes ||= "#{name} #{@cf.field_format}"
84 @css_classes ||= "#{name} #{@cf.field_format}"
85 end
85 end
86 end
86 end
87
87
88 class Query < ActiveRecord::Base
88 class Query < ActiveRecord::Base
89 class StatementInvalid < ::ActiveRecord::StatementInvalid
89 class StatementInvalid < ::ActiveRecord::StatementInvalid
90 end
90 end
91
91
92 belongs_to :project
92 belongs_to :project
93 belongs_to :user
93 belongs_to :user
94 serialize :filters
94 serialize :filters
95 serialize :column_names
95 serialize :column_names
96 serialize :sort_criteria, Array
96 serialize :sort_criteria, Array
97
97
98 attr_protected :project_id, :user_id
98 attr_protected :project_id, :user_id
99
99
100 validates_presence_of :name
100 validates_presence_of :name
101 validates_length_of :name, :maximum => 255
101 validates_length_of :name, :maximum => 255
102 validate :validate_query_filters
102 validate :validate_query_filters
103
103
104 @@operators = { "=" => :label_equals,
104 @@operators = { "=" => :label_equals,
105 "!" => :label_not_equals,
105 "!" => :label_not_equals,
106 "o" => :label_open_issues,
106 "o" => :label_open_issues,
107 "c" => :label_closed_issues,
107 "c" => :label_closed_issues,
108 "!*" => :label_none,
108 "!*" => :label_none,
109 "*" => :label_any,
109 "*" => :label_any,
110 ">=" => :label_greater_or_equal,
110 ">=" => :label_greater_or_equal,
111 "<=" => :label_less_or_equal,
111 "<=" => :label_less_or_equal,
112 "><" => :label_between,
112 "><" => :label_between,
113 "<t+" => :label_in_less_than,
113 "<t+" => :label_in_less_than,
114 ">t+" => :label_in_more_than,
114 ">t+" => :label_in_more_than,
115 "><t+"=> :label_in_the_next_days,
115 "><t+"=> :label_in_the_next_days,
116 "t+" => :label_in,
116 "t+" => :label_in,
117 "t" => :label_today,
117 "t" => :label_today,
118 "w" => :label_this_week,
118 "w" => :label_this_week,
119 ">t-" => :label_less_than_ago,
119 ">t-" => :label_less_than_ago,
120 "<t-" => :label_more_than_ago,
120 "<t-" => :label_more_than_ago,
121 "><t-"=> :label_in_the_past_days,
121 "><t-"=> :label_in_the_past_days,
122 "t-" => :label_ago,
122 "t-" => :label_ago,
123 "~" => :label_contains,
123 "~" => :label_contains,
124 "!~" => :label_not_contains,
124 "!~" => :label_not_contains,
125 "=p" => :label_any_issues_in_project,
125 "=p" => :label_any_issues_in_project,
126 "=!p" => :label_any_issues_not_in_project,
126 "=!p" => :label_any_issues_not_in_project,
127 "!p" => :label_no_issues_in_project}
127 "!p" => :label_no_issues_in_project}
128
128
129 cattr_reader :operators
129 cattr_reader :operators
130
130
131 @@operators_by_filter_type = { :list => [ "=", "!" ],
131 @@operators_by_filter_type = { :list => [ "=", "!" ],
132 :list_status => [ "o", "=", "!", "c", "*" ],
132 :list_status => [ "o", "=", "!", "c", "*" ],
133 :list_optional => [ "=", "!", "!*", "*" ],
133 :list_optional => [ "=", "!", "!*", "*" ],
134 :list_subprojects => [ "*", "!*", "=" ],
134 :list_subprojects => [ "*", "!*", "=" ],
135 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "w", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
135 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "w", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
136 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "w", "!*", "*" ],
136 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "w", "!*", "*" ],
137 :string => [ "=", "~", "!", "!~", "!*", "*" ],
137 :string => [ "=", "~", "!", "!~", "!*", "*" ],
138 :text => [ "~", "!~", "!*", "*" ],
138 :text => [ "~", "!~", "!*", "*" ],
139 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
139 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
140 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
140 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
141 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]}
141 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]}
142
142
143 cattr_reader :operators_by_filter_type
143 cattr_reader :operators_by_filter_type
144
144
145 @@available_columns = [
145 @@available_columns = [
146 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
146 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
147 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
147 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
148 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
148 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
149 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
149 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
150 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
150 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
151 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
151 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
152 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
152 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
153 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
153 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
154 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
154 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
155 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
155 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
156 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
156 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
157 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
157 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
158 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
158 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
159 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
159 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
160 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
160 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
161 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
161 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
162 QueryColumn.new(:relations, :caption => :label_related_issues),
162 QueryColumn.new(:relations, :caption => :label_related_issues),
163 QueryColumn.new(:description, :inline => false)
163 QueryColumn.new(:description, :inline => false)
164 ]
164 ]
165 cattr_reader :available_columns
165 cattr_reader :available_columns
166
166
167 scope :visible, lambda {|*args|
167 scope :visible, lambda {|*args|
168 user = args.shift || User.current
168 user = args.shift || User.current
169 base = Project.allowed_to_condition(user, :view_issues, *args)
169 base = Project.allowed_to_condition(user, :view_issues, *args)
170 user_id = user.logged? ? user.id : 0
170 user_id = user.logged? ? user.id : 0
171 {
171
172 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
172 includes(:project).where("(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id)
173 :include => :project
174 }
175 }
173 }
176
174
177 def initialize(attributes=nil, *args)
175 def initialize(attributes=nil, *args)
178 super attributes
176 super attributes
179 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
177 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
180 @is_for_all = project.nil?
178 @is_for_all = project.nil?
181 end
179 end
182
180
183 def validate_query_filters
181 def validate_query_filters
184 filters.each_key do |field|
182 filters.each_key do |field|
185 if values_for(field)
183 if values_for(field)
186 case type_for(field)
184 case type_for(field)
187 when :integer
185 when :integer
188 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
186 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
189 when :float
187 when :float
190 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
188 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
191 when :date, :date_past
189 when :date, :date_past
192 case operator_for(field)
190 case operator_for(field)
193 when "=", ">=", "<=", "><"
191 when "=", ">=", "<=", "><"
194 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?) }
192 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?) }
195 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
193 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
196 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
194 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
197 end
195 end
198 end
196 end
199 end
197 end
200
198
201 add_filter_error(field, :blank) unless
199 add_filter_error(field, :blank) unless
202 # filter requires one or more values
200 # filter requires one or more values
203 (values_for(field) and !values_for(field).first.blank?) or
201 (values_for(field) and !values_for(field).first.blank?) or
204 # filter doesn't require any value
202 # filter doesn't require any value
205 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
203 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
206 end if filters
204 end if filters
207 end
205 end
208
206
209 def add_filter_error(field, message)
207 def add_filter_error(field, message)
210 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
208 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
211 errors.add(:base, m)
209 errors.add(:base, m)
212 end
210 end
213
211
214 # Returns true if the query is visible to +user+ or the current user.
212 # Returns true if the query is visible to +user+ or the current user.
215 def visible?(user=User.current)
213 def visible?(user=User.current)
216 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
214 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
217 end
215 end
218
216
219 def editable_by?(user)
217 def editable_by?(user)
220 return false unless user
218 return false unless user
221 # Admin can edit them all and regular users can edit their private queries
219 # Admin can edit them all and regular users can edit their private queries
222 return true if user.admin? || (!is_public && self.user_id == user.id)
220 return true if user.admin? || (!is_public && self.user_id == user.id)
223 # Members can not edit public queries that are for all project (only admin is allowed to)
221 # Members can not edit public queries that are for all project (only admin is allowed to)
224 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
222 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
225 end
223 end
226
224
227 def trackers
225 def trackers
228 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
226 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
229 end
227 end
230
228
231 # Returns a hash of localized labels for all filter operators
229 # Returns a hash of localized labels for all filter operators
232 def self.operators_labels
230 def self.operators_labels
233 operators.inject({}) {|h, operator| h[operator.first] = l(operator.last); h}
231 operators.inject({}) {|h, operator| h[operator.first] = l(operator.last); h}
234 end
232 end
235
233
236 def available_filters
234 def available_filters
237 return @available_filters if @available_filters
235 return @available_filters if @available_filters
238 @available_filters = {
236 @available_filters = {
239 "status_id" => {
237 "status_id" => {
240 :type => :list_status, :order => 0,
238 :type => :list_status, :order => 0,
241 :values => IssueStatus.sorted.all.collect{|s| [s.name, s.id.to_s] }
239 :values => IssueStatus.sorted.all.collect{|s| [s.name, s.id.to_s] }
242 },
240 },
243 "tracker_id" => {
241 "tracker_id" => {
244 :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] }
242 :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] }
245 },
243 },
246 "priority_id" => {
244 "priority_id" => {
247 :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
245 :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
248 },
246 },
249 "subject" => { :type => :text, :order => 8 },
247 "subject" => { :type => :text, :order => 8 },
250 "created_on" => { :type => :date_past, :order => 9 },
248 "created_on" => { :type => :date_past, :order => 9 },
251 "updated_on" => { :type => :date_past, :order => 10 },
249 "updated_on" => { :type => :date_past, :order => 10 },
252 "start_date" => { :type => :date, :order => 11 },
250 "start_date" => { :type => :date, :order => 11 },
253 "due_date" => { :type => :date, :order => 12 },
251 "due_date" => { :type => :date, :order => 12 },
254 "estimated_hours" => { :type => :float, :order => 13 },
252 "estimated_hours" => { :type => :float, :order => 13 },
255 "done_ratio" => { :type => :integer, :order => 14 }
253 "done_ratio" => { :type => :integer, :order => 14 }
256 }
254 }
257 IssueRelation::TYPES.each do |relation_type, options|
255 IssueRelation::TYPES.each do |relation_type, options|
258 @available_filters[relation_type] = {
256 @available_filters[relation_type] = {
259 :type => :relation, :order => @available_filters.size + 100,
257 :type => :relation, :order => @available_filters.size + 100,
260 :label => options[:name]
258 :label => options[:name]
261 }
259 }
262 end
260 end
263 principals = []
261 principals = []
264 if project
262 if project
265 principals += project.principals.sort
263 principals += project.principals.sort
266 unless project.leaf?
264 unless project.leaf?
267 subprojects = project.descendants.visible.all
265 subprojects = project.descendants.visible.all
268 if subprojects.any?
266 if subprojects.any?
269 @available_filters["subproject_id"] = {
267 @available_filters["subproject_id"] = {
270 :type => :list_subprojects, :order => 13,
268 :type => :list_subprojects, :order => 13,
271 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
269 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
272 }
270 }
273 principals += Principal.member_of(subprojects)
271 principals += Principal.member_of(subprojects)
274 end
272 end
275 end
273 end
276 else
274 else
277 if all_projects.any?
275 if all_projects.any?
278 # members of visible projects
276 # members of visible projects
279 principals += Principal.member_of(all_projects)
277 principals += Principal.member_of(all_projects)
280 # project filter
278 # project filter
281 project_values = []
279 project_values = []
282 if User.current.logged? && User.current.memberships.any?
280 if User.current.logged? && User.current.memberships.any?
283 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
281 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
284 end
282 end
285 project_values += all_projects_values
283 project_values += all_projects_values
286 @available_filters["project_id"] = {
284 @available_filters["project_id"] = {
287 :type => :list, :order => 1, :values => project_values
285 :type => :list, :order => 1, :values => project_values
288 } unless project_values.empty?
286 } unless project_values.empty?
289 end
287 end
290 end
288 end
291 principals.uniq!
289 principals.uniq!
292 principals.sort!
290 principals.sort!
293 users = principals.select {|p| p.is_a?(User)}
291 users = principals.select {|p| p.is_a?(User)}
294
292
295 assigned_to_values = []
293 assigned_to_values = []
296 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
294 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
297 assigned_to_values += (Setting.issue_group_assignment? ?
295 assigned_to_values += (Setting.issue_group_assignment? ?
298 principals : users).collect{|s| [s.name, s.id.to_s] }
296 principals : users).collect{|s| [s.name, s.id.to_s] }
299 @available_filters["assigned_to_id"] = {
297 @available_filters["assigned_to_id"] = {
300 :type => :list_optional, :order => 4, :values => assigned_to_values
298 :type => :list_optional, :order => 4, :values => assigned_to_values
301 } unless assigned_to_values.empty?
299 } unless assigned_to_values.empty?
302
300
303 author_values = []
301 author_values = []
304 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
302 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
305 author_values += users.collect{|s| [s.name, s.id.to_s] }
303 author_values += users.collect{|s| [s.name, s.id.to_s] }
306 @available_filters["author_id"] = {
304 @available_filters["author_id"] = {
307 :type => :list, :order => 5, :values => author_values
305 :type => :list, :order => 5, :values => author_values
308 } unless author_values.empty?
306 } unless author_values.empty?
309
307
310 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
308 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
311 @available_filters["member_of_group"] = {
309 @available_filters["member_of_group"] = {
312 :type => :list_optional, :order => 6, :values => group_values
310 :type => :list_optional, :order => 6, :values => group_values
313 } unless group_values.empty?
311 } unless group_values.empty?
314
312
315 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
313 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
316 @available_filters["assigned_to_role"] = {
314 @available_filters["assigned_to_role"] = {
317 :type => :list_optional, :order => 7, :values => role_values
315 :type => :list_optional, :order => 7, :values => role_values
318 } unless role_values.empty?
316 } unless role_values.empty?
319
317
320 if User.current.logged?
318 if User.current.logged?
321 @available_filters["watcher_id"] = {
319 @available_filters["watcher_id"] = {
322 :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]]
320 :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]]
323 }
321 }
324 end
322 end
325
323
326 if project
324 if project
327 # project specific filters
325 # project specific filters
328 categories = project.issue_categories.all
326 categories = project.issue_categories.all
329 unless categories.empty?
327 unless categories.empty?
330 @available_filters["category_id"] = {
328 @available_filters["category_id"] = {
331 :type => :list_optional, :order => 6,
329 :type => :list_optional, :order => 6,
332 :values => categories.collect{|s| [s.name, s.id.to_s] }
330 :values => categories.collect{|s| [s.name, s.id.to_s] }
333 }
331 }
334 end
332 end
335 versions = project.shared_versions.all
333 versions = project.shared_versions.all
336 unless versions.empty?
334 unless versions.empty?
337 @available_filters["fixed_version_id"] = {
335 @available_filters["fixed_version_id"] = {
338 :type => :list_optional, :order => 7,
336 :type => :list_optional, :order => 7,
339 :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
337 :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
340 }
338 }
341 end
339 end
342 add_custom_fields_filters(project.all_issue_custom_fields)
340 add_custom_fields_filters(project.all_issue_custom_fields)
343 else
341 else
344 # global filters for cross project issue list
342 # global filters for cross project issue list
345 system_shared_versions = Version.visible.find_all_by_sharing('system')
343 system_shared_versions = Version.visible.find_all_by_sharing('system')
346 unless system_shared_versions.empty?
344 unless system_shared_versions.empty?
347 @available_filters["fixed_version_id"] = {
345 @available_filters["fixed_version_id"] = {
348 :type => :list_optional, :order => 7,
346 :type => :list_optional, :order => 7,
349 :values => system_shared_versions.sort.collect{|s|
347 :values => system_shared_versions.sort.collect{|s|
350 ["#{s.project.name} - #{s.name}", s.id.to_s]
348 ["#{s.project.name} - #{s.name}", s.id.to_s]
351 }
349 }
352 }
350 }
353 end
351 end
354 add_custom_fields_filters(IssueCustomField.where(:is_filter => true, :is_for_all => true).all)
352 add_custom_fields_filters(IssueCustomField.where(:is_filter => true, :is_for_all => true).all)
355 end
353 end
356 add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
354 add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
357 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
355 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
358 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
356 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
359 @available_filters["is_private"] = {
357 @available_filters["is_private"] = {
360 :type => :list, :order => 16,
358 :type => :list, :order => 16,
361 :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
359 :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
362 }
360 }
363 end
361 end
364 Tracker.disabled_core_fields(trackers).each {|field|
362 Tracker.disabled_core_fields(trackers).each {|field|
365 @available_filters.delete field
363 @available_filters.delete field
366 }
364 }
367 @available_filters.each do |field, options|
365 @available_filters.each do |field, options|
368 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
366 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
369 end
367 end
370 @available_filters
368 @available_filters
371 end
369 end
372
370
373 # Returns a representation of the available filters for JSON serialization
371 # Returns a representation of the available filters for JSON serialization
374 def available_filters_as_json
372 def available_filters_as_json
375 json = {}
373 json = {}
376 available_filters.each do |field, options|
374 available_filters.each do |field, options|
377 json[field] = options.slice(:type, :name, :values).stringify_keys
375 json[field] = options.slice(:type, :name, :values).stringify_keys
378 end
376 end
379 json
377 json
380 end
378 end
381
379
382 def all_projects
380 def all_projects
383 @all_projects ||= Project.visible.all
381 @all_projects ||= Project.visible.all
384 end
382 end
385
383
386 def all_projects_values
384 def all_projects_values
387 return @all_projects_values if @all_projects_values
385 return @all_projects_values if @all_projects_values
388
386
389 values = []
387 values = []
390 Project.project_tree(all_projects) do |p, level|
388 Project.project_tree(all_projects) do |p, level|
391 prefix = (level > 0 ? ('--' * level + ' ') : '')
389 prefix = (level > 0 ? ('--' * level + ' ') : '')
392 values << ["#{prefix}#{p.name}", p.id.to_s]
390 values << ["#{prefix}#{p.name}", p.id.to_s]
393 end
391 end
394 @all_projects_values = values
392 @all_projects_values = values
395 end
393 end
396
394
397 def add_filter(field, operator, values)
395 def add_filter(field, operator, values)
398 # values must be an array
396 # values must be an array
399 return unless values.nil? || values.is_a?(Array)
397 return unless values.nil? || values.is_a?(Array)
400 # check if field is defined as an available filter
398 # check if field is defined as an available filter
401 if available_filters.has_key? field
399 if available_filters.has_key? field
402 filter_options = available_filters[field]
400 filter_options = available_filters[field]
403 # check if operator is allowed for that filter
401 # check if operator is allowed for that filter
404 #if @@operators_by_filter_type[filter_options[:type]].include? operator
402 #if @@operators_by_filter_type[filter_options[:type]].include? operator
405 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
403 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
406 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
404 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
407 #end
405 #end
408 filters[field] = {:operator => operator, :values => (values || [''])}
406 filters[field] = {:operator => operator, :values => (values || [''])}
409 end
407 end
410 end
408 end
411
409
412 def add_short_filter(field, expression)
410 def add_short_filter(field, expression)
413 return unless expression && available_filters.has_key?(field)
411 return unless expression && available_filters.has_key?(field)
414 field_type = available_filters[field][:type]
412 field_type = available_filters[field][:type]
415 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
413 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
416 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
414 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
417 add_filter field, operator, $1.present? ? $1.split('|') : ['']
415 add_filter field, operator, $1.present? ? $1.split('|') : ['']
418 end || add_filter(field, '=', expression.split('|'))
416 end || add_filter(field, '=', expression.split('|'))
419 end
417 end
420
418
421 # Add multiple filters using +add_filter+
419 # Add multiple filters using +add_filter+
422 def add_filters(fields, operators, values)
420 def add_filters(fields, operators, values)
423 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
421 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
424 fields.each do |field|
422 fields.each do |field|
425 add_filter(field, operators[field], values && values[field])
423 add_filter(field, operators[field], values && values[field])
426 end
424 end
427 end
425 end
428 end
426 end
429
427
430 def has_filter?(field)
428 def has_filter?(field)
431 filters and filters[field]
429 filters and filters[field]
432 end
430 end
433
431
434 def type_for(field)
432 def type_for(field)
435 available_filters[field][:type] if available_filters.has_key?(field)
433 available_filters[field][:type] if available_filters.has_key?(field)
436 end
434 end
437
435
438 def operator_for(field)
436 def operator_for(field)
439 has_filter?(field) ? filters[field][:operator] : nil
437 has_filter?(field) ? filters[field][:operator] : nil
440 end
438 end
441
439
442 def values_for(field)
440 def values_for(field)
443 has_filter?(field) ? filters[field][:values] : nil
441 has_filter?(field) ? filters[field][:values] : nil
444 end
442 end
445
443
446 def value_for(field, index=0)
444 def value_for(field, index=0)
447 (values_for(field) || [])[index]
445 (values_for(field) || [])[index]
448 end
446 end
449
447
450 def label_for(field)
448 def label_for(field)
451 label = available_filters[field][:name] if available_filters.has_key?(field)
449 label = available_filters[field][:name] if available_filters.has_key?(field)
452 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
450 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
453 end
451 end
454
452
455 def available_columns
453 def available_columns
456 return @available_columns if @available_columns
454 return @available_columns if @available_columns
457 @available_columns = ::Query.available_columns.dup
455 @available_columns = ::Query.available_columns.dup
458 @available_columns += (project ?
456 @available_columns += (project ?
459 project.all_issue_custom_fields :
457 project.all_issue_custom_fields :
460 IssueCustomField.all
458 IssueCustomField.all
461 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
459 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
462
460
463 if User.current.allowed_to?(:view_time_entries, project, :global => true)
461 if User.current.allowed_to?(:view_time_entries, project, :global => true)
464 index = nil
462 index = nil
465 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
463 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
466 index = (index ? index + 1 : -1)
464 index = (index ? index + 1 : -1)
467 # insert the column after estimated_hours or at the end
465 # insert the column after estimated_hours or at the end
468 @available_columns.insert index, QueryColumn.new(:spent_hours,
466 @available_columns.insert index, QueryColumn.new(:spent_hours,
469 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
467 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
470 :default_order => 'desc',
468 :default_order => 'desc',
471 :caption => :label_spent_time
469 :caption => :label_spent_time
472 )
470 )
473 end
471 end
474
472
475 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
473 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
476 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
474 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
477 @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
475 @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
478 end
476 end
479
477
480 disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
478 disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
481 @available_columns.reject! {|column|
479 @available_columns.reject! {|column|
482 disabled_fields.include?(column.name.to_s)
480 disabled_fields.include?(column.name.to_s)
483 }
481 }
484
482
485 @available_columns
483 @available_columns
486 end
484 end
487
485
488 def self.available_columns=(v)
486 def self.available_columns=(v)
489 self.available_columns = (v)
487 self.available_columns = (v)
490 end
488 end
491
489
492 def self.add_available_column(column)
490 def self.add_available_column(column)
493 self.available_columns << (column) if column.is_a?(QueryColumn)
491 self.available_columns << (column) if column.is_a?(QueryColumn)
494 end
492 end
495
493
496 # Returns an array of columns that can be used to group the results
494 # Returns an array of columns that can be used to group the results
497 def groupable_columns
495 def groupable_columns
498 available_columns.select {|c| c.groupable}
496 available_columns.select {|c| c.groupable}
499 end
497 end
500
498
501 # Returns a Hash of columns and the key for sorting
499 # Returns a Hash of columns and the key for sorting
502 def sortable_columns
500 def sortable_columns
503 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
501 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
504 h[column.name.to_s] = column.sortable
502 h[column.name.to_s] = column.sortable
505 h
503 h
506 })
504 })
507 end
505 end
508
506
509 def columns
507 def columns
510 # preserve the column_names order
508 # preserve the column_names order
511 (has_default_columns? ? default_columns_names : column_names).collect do |name|
509 (has_default_columns? ? default_columns_names : column_names).collect do |name|
512 available_columns.find { |col| col.name == name }
510 available_columns.find { |col| col.name == name }
513 end.compact
511 end.compact
514 end
512 end
515
513
516 def inline_columns
514 def inline_columns
517 columns.select(&:inline?)
515 columns.select(&:inline?)
518 end
516 end
519
517
520 def block_columns
518 def block_columns
521 columns.reject(&:inline?)
519 columns.reject(&:inline?)
522 end
520 end
523
521
524 def available_inline_columns
522 def available_inline_columns
525 available_columns.select(&:inline?)
523 available_columns.select(&:inline?)
526 end
524 end
527
525
528 def available_block_columns
526 def available_block_columns
529 available_columns.reject(&:inline?)
527 available_columns.reject(&:inline?)
530 end
528 end
531
529
532 def default_columns_names
530 def default_columns_names
533 @default_columns_names ||= begin
531 @default_columns_names ||= begin
534 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
532 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
535
533
536 project.present? ? default_columns : [:project] | default_columns
534 project.present? ? default_columns : [:project] | default_columns
537 end
535 end
538 end
536 end
539
537
540 def column_names=(names)
538 def column_names=(names)
541 if names
539 if names
542 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
540 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
543 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
541 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
544 # Set column_names to nil if default columns
542 # Set column_names to nil if default columns
545 if names == default_columns_names
543 if names == default_columns_names
546 names = nil
544 names = nil
547 end
545 end
548 end
546 end
549 write_attribute(:column_names, names)
547 write_attribute(:column_names, names)
550 end
548 end
551
549
552 def has_column?(column)
550 def has_column?(column)
553 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
551 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
554 end
552 end
555
553
556 def has_default_columns?
554 def has_default_columns?
557 column_names.nil? || column_names.empty?
555 column_names.nil? || column_names.empty?
558 end
556 end
559
557
560 def sort_criteria=(arg)
558 def sort_criteria=(arg)
561 c = []
559 c = []
562 if arg.is_a?(Hash)
560 if arg.is_a?(Hash)
563 arg = arg.keys.sort.collect {|k| arg[k]}
561 arg = arg.keys.sort.collect {|k| arg[k]}
564 end
562 end
565 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
563 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
566 write_attribute(:sort_criteria, c)
564 write_attribute(:sort_criteria, c)
567 end
565 end
568
566
569 def sort_criteria
567 def sort_criteria
570 read_attribute(:sort_criteria) || []
568 read_attribute(:sort_criteria) || []
571 end
569 end
572
570
573 def sort_criteria_key(arg)
571 def sort_criteria_key(arg)
574 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
572 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
575 end
573 end
576
574
577 def sort_criteria_order(arg)
575 def sort_criteria_order(arg)
578 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
576 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
579 end
577 end
580
578
581 def sort_criteria_order_for(key)
579 def sort_criteria_order_for(key)
582 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
580 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
583 end
581 end
584
582
585 # Returns the SQL sort order that should be prepended for grouping
583 # Returns the SQL sort order that should be prepended for grouping
586 def group_by_sort_order
584 def group_by_sort_order
587 if grouped? && (column = group_by_column)
585 if grouped? && (column = group_by_column)
588 order = sort_criteria_order_for(column.name) || column.default_order
586 order = sort_criteria_order_for(column.name) || column.default_order
589 column.sortable.is_a?(Array) ?
587 column.sortable.is_a?(Array) ?
590 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
588 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
591 "#{column.sortable} #{order}"
589 "#{column.sortable} #{order}"
592 end
590 end
593 end
591 end
594
592
595 # Returns true if the query is a grouped query
593 # Returns true if the query is a grouped query
596 def grouped?
594 def grouped?
597 !group_by_column.nil?
595 !group_by_column.nil?
598 end
596 end
599
597
600 def group_by_column
598 def group_by_column
601 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
599 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
602 end
600 end
603
601
604 def group_by_statement
602 def group_by_statement
605 group_by_column.try(:groupable)
603 group_by_column.try(:groupable)
606 end
604 end
607
605
608 def project_statement
606 def project_statement
609 project_clauses = []
607 project_clauses = []
610 if project && !project.descendants.active.empty?
608 if project && !project.descendants.active.empty?
611 ids = [project.id]
609 ids = [project.id]
612 if has_filter?("subproject_id")
610 if has_filter?("subproject_id")
613 case operator_for("subproject_id")
611 case operator_for("subproject_id")
614 when '='
612 when '='
615 # include the selected subprojects
613 # include the selected subprojects
616 ids += values_for("subproject_id").each(&:to_i)
614 ids += values_for("subproject_id").each(&:to_i)
617 when '!*'
615 when '!*'
618 # main project only
616 # main project only
619 else
617 else
620 # all subprojects
618 # all subprojects
621 ids += project.descendants.collect(&:id)
619 ids += project.descendants.collect(&:id)
622 end
620 end
623 elsif Setting.display_subprojects_issues?
621 elsif Setting.display_subprojects_issues?
624 ids += project.descendants.collect(&:id)
622 ids += project.descendants.collect(&:id)
625 end
623 end
626 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
624 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
627 elsif project
625 elsif project
628 project_clauses << "#{Project.table_name}.id = %d" % project.id
626 project_clauses << "#{Project.table_name}.id = %d" % project.id
629 end
627 end
630 project_clauses.any? ? project_clauses.join(' AND ') : nil
628 project_clauses.any? ? project_clauses.join(' AND ') : nil
631 end
629 end
632
630
633 def statement
631 def statement
634 # filters clauses
632 # filters clauses
635 filters_clauses = []
633 filters_clauses = []
636 filters.each_key do |field|
634 filters.each_key do |field|
637 next if field == "subproject_id"
635 next if field == "subproject_id"
638 v = values_for(field).clone
636 v = values_for(field).clone
639 next unless v and !v.empty?
637 next unless v and !v.empty?
640 operator = operator_for(field)
638 operator = operator_for(field)
641
639
642 # "me" value subsitution
640 # "me" value subsitution
643 if %w(assigned_to_id author_id watcher_id).include?(field)
641 if %w(assigned_to_id author_id watcher_id).include?(field)
644 if v.delete("me")
642 if v.delete("me")
645 if User.current.logged?
643 if User.current.logged?
646 v.push(User.current.id.to_s)
644 v.push(User.current.id.to_s)
647 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
645 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
648 else
646 else
649 v.push("0")
647 v.push("0")
650 end
648 end
651 end
649 end
652 end
650 end
653
651
654 if field == 'project_id'
652 if field == 'project_id'
655 if v.delete('mine')
653 if v.delete('mine')
656 v += User.current.memberships.map(&:project_id).map(&:to_s)
654 v += User.current.memberships.map(&:project_id).map(&:to_s)
657 end
655 end
658 end
656 end
659
657
660 if field =~ /cf_(\d+)$/
658 if field =~ /cf_(\d+)$/
661 # custom field
659 # custom field
662 filters_clauses << sql_for_custom_field(field, operator, v, $1)
660 filters_clauses << sql_for_custom_field(field, operator, v, $1)
663 elsif respond_to?("sql_for_#{field}_field")
661 elsif respond_to?("sql_for_#{field}_field")
664 # specific statement
662 # specific statement
665 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
663 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
666 else
664 else
667 # regular field
665 # regular field
668 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
666 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
669 end
667 end
670 end if filters and valid?
668 end if filters and valid?
671
669
672 filters_clauses << project_statement
670 filters_clauses << project_statement
673 filters_clauses.reject!(&:blank?)
671 filters_clauses.reject!(&:blank?)
674
672
675 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
673 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
676 end
674 end
677
675
678 # Returns the issue count
676 # Returns the issue count
679 def issue_count
677 def issue_count
680 Issue.visible.count(:include => [:status, :project], :conditions => statement)
678 Issue.visible.count(:include => [:status, :project], :conditions => statement)
681 rescue ::ActiveRecord::StatementInvalid => e
679 rescue ::ActiveRecord::StatementInvalid => e
682 raise StatementInvalid.new(e.message)
680 raise StatementInvalid.new(e.message)
683 end
681 end
684
682
685 # Returns the issue count by group or nil if query is not grouped
683 # Returns the issue count by group or nil if query is not grouped
686 def issue_count_by_group
684 def issue_count_by_group
687 r = nil
685 r = nil
688 if grouped?
686 if grouped?
689 begin
687 begin
690 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
688 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
691 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
689 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
692 rescue ActiveRecord::RecordNotFound
690 rescue ActiveRecord::RecordNotFound
693 r = {nil => issue_count}
691 r = {nil => issue_count}
694 end
692 end
695 c = group_by_column
693 c = group_by_column
696 if c.is_a?(QueryCustomFieldColumn)
694 if c.is_a?(QueryCustomFieldColumn)
697 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
695 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
698 end
696 end
699 end
697 end
700 r
698 r
701 rescue ::ActiveRecord::StatementInvalid => e
699 rescue ::ActiveRecord::StatementInvalid => e
702 raise StatementInvalid.new(e.message)
700 raise StatementInvalid.new(e.message)
703 end
701 end
704
702
705 # Returns the issues
703 # Returns the issues
706 # Valid options are :order, :offset, :limit, :include, :conditions
704 # Valid options are :order, :offset, :limit, :include, :conditions
707 def issues(options={})
705 def issues(options={})
708 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
706 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
709 order_option = nil if order_option.blank?
707 order_option = nil if order_option.blank?
710
708
711 issues = Issue.visible.where(options[:conditions]).all(
709 issues = Issue.visible.where(options[:conditions]).all(
712 :include => ([:status, :project] + (options[:include] || [])).uniq,
710 :include => ([:status, :project] + (options[:include] || [])).uniq,
713 :conditions => statement,
711 :conditions => statement,
714 :order => order_option,
712 :order => order_option,
715 :joins => joins_for_order_statement(order_option),
713 :joins => joins_for_order_statement(order_option),
716 :limit => options[:limit],
714 :limit => options[:limit],
717 :offset => options[:offset]
715 :offset => options[:offset]
718 )
716 )
719
717
720 if has_column?(:spent_hours)
718 if has_column?(:spent_hours)
721 Issue.load_visible_spent_hours(issues)
719 Issue.load_visible_spent_hours(issues)
722 end
720 end
723 if has_column?(:relations)
721 if has_column?(:relations)
724 Issue.load_visible_relations(issues)
722 Issue.load_visible_relations(issues)
725 end
723 end
726 issues
724 issues
727 rescue ::ActiveRecord::StatementInvalid => e
725 rescue ::ActiveRecord::StatementInvalid => e
728 raise StatementInvalid.new(e.message)
726 raise StatementInvalid.new(e.message)
729 end
727 end
730
728
731 # Returns the issues ids
729 # Returns the issues ids
732 def issue_ids(options={})
730 def issue_ids(options={})
733 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
731 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
734 order_option = nil if order_option.blank?
732 order_option = nil if order_option.blank?
735
733
736 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
734 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
737 :conditions => statement,
735 :conditions => statement,
738 :order => order_option,
736 :order => order_option,
739 :joins => joins_for_order_statement(order_option),
737 :joins => joins_for_order_statement(order_option),
740 :limit => options[:limit],
738 :limit => options[:limit],
741 :offset => options[:offset]).find_ids
739 :offset => options[:offset]).find_ids
742 rescue ::ActiveRecord::StatementInvalid => e
740 rescue ::ActiveRecord::StatementInvalid => e
743 raise StatementInvalid.new(e.message)
741 raise StatementInvalid.new(e.message)
744 end
742 end
745
743
746 # Returns the journals
744 # Returns the journals
747 # Valid options are :order, :offset, :limit
745 # Valid options are :order, :offset, :limit
748 def journals(options={})
746 def journals(options={})
749 Journal.visible.all(
747 Journal.visible.all(
750 :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
748 :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
751 :conditions => statement,
749 :conditions => statement,
752 :order => options[:order],
750 :order => options[:order],
753 :limit => options[:limit],
751 :limit => options[:limit],
754 :offset => options[:offset]
752 :offset => options[:offset]
755 )
753 )
756 rescue ::ActiveRecord::StatementInvalid => e
754 rescue ::ActiveRecord::StatementInvalid => e
757 raise StatementInvalid.new(e.message)
755 raise StatementInvalid.new(e.message)
758 end
756 end
759
757
760 # Returns the versions
758 # Returns the versions
761 # Valid options are :conditions
759 # Valid options are :conditions
762 def versions(options={})
760 def versions(options={})
763 Version.visible.where(options[:conditions]).all(
761 Version.visible.where(options[:conditions]).all(
764 :include => :project,
762 :include => :project,
765 :conditions => project_statement
763 :conditions => project_statement
766 )
764 )
767 rescue ::ActiveRecord::StatementInvalid => e
765 rescue ::ActiveRecord::StatementInvalid => e
768 raise StatementInvalid.new(e.message)
766 raise StatementInvalid.new(e.message)
769 end
767 end
770
768
771 def sql_for_watcher_id_field(field, operator, value)
769 def sql_for_watcher_id_field(field, operator, value)
772 db_table = Watcher.table_name
770 db_table = Watcher.table_name
773 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
771 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
774 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
772 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
775 end
773 end
776
774
777 def sql_for_member_of_group_field(field, operator, value)
775 def sql_for_member_of_group_field(field, operator, value)
778 if operator == '*' # Any group
776 if operator == '*' # Any group
779 groups = Group.all
777 groups = Group.all
780 operator = '=' # Override the operator since we want to find by assigned_to
778 operator = '=' # Override the operator since we want to find by assigned_to
781 elsif operator == "!*"
779 elsif operator == "!*"
782 groups = Group.all
780 groups = Group.all
783 operator = '!' # Override the operator since we want to find by assigned_to
781 operator = '!' # Override the operator since we want to find by assigned_to
784 else
782 else
785 groups = Group.find_all_by_id(value)
783 groups = Group.find_all_by_id(value)
786 end
784 end
787 groups ||= []
785 groups ||= []
788
786
789 members_of_groups = groups.inject([]) {|user_ids, group|
787 members_of_groups = groups.inject([]) {|user_ids, group|
790 if group && group.user_ids.present?
788 if group && group.user_ids.present?
791 user_ids << group.user_ids
789 user_ids << group.user_ids
792 end
790 end
793 user_ids.flatten.uniq.compact
791 user_ids.flatten.uniq.compact
794 }.sort.collect(&:to_s)
792 }.sort.collect(&:to_s)
795
793
796 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
794 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
797 end
795 end
798
796
799 def sql_for_assigned_to_role_field(field, operator, value)
797 def sql_for_assigned_to_role_field(field, operator, value)
800 case operator
798 case operator
801 when "*", "!*" # Member / Not member
799 when "*", "!*" # Member / Not member
802 sw = operator == "!*" ? 'NOT' : ''
800 sw = operator == "!*" ? 'NOT' : ''
803 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
801 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
804 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
802 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
805 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
803 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
806 when "=", "!"
804 when "=", "!"
807 role_cond = value.any? ?
805 role_cond = value.any? ?
808 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
806 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
809 "1=0"
807 "1=0"
810
808
811 sw = operator == "!" ? 'NOT' : ''
809 sw = operator == "!" ? 'NOT' : ''
812 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
810 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
813 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
811 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
814 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
812 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
815 end
813 end
816 end
814 end
817
815
818 def sql_for_is_private_field(field, operator, value)
816 def sql_for_is_private_field(field, operator, value)
819 op = (operator == "=" ? 'IN' : 'NOT IN')
817 op = (operator == "=" ? 'IN' : 'NOT IN')
820 va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
818 va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
821
819
822 "#{Issue.table_name}.is_private #{op} (#{va})"
820 "#{Issue.table_name}.is_private #{op} (#{va})"
823 end
821 end
824
822
825 def sql_for_relations(field, operator, value, options={})
823 def sql_for_relations(field, operator, value, options={})
826 relation_options = IssueRelation::TYPES[field]
824 relation_options = IssueRelation::TYPES[field]
827 return relation_options unless relation_options
825 return relation_options unless relation_options
828
826
829 relation_type = field
827 relation_type = field
830 join_column, target_join_column = "issue_from_id", "issue_to_id"
828 join_column, target_join_column = "issue_from_id", "issue_to_id"
831 if relation_options[:reverse] || options[:reverse]
829 if relation_options[:reverse] || options[:reverse]
832 relation_type = relation_options[:reverse] || relation_type
830 relation_type = relation_options[:reverse] || relation_type
833 join_column, target_join_column = target_join_column, join_column
831 join_column, target_join_column = target_join_column, join_column
834 end
832 end
835
833
836 sql = case operator
834 sql = case operator
837 when "*", "!*"
835 when "*", "!*"
838 op = (operator == "*" ? 'IN' : 'NOT IN')
836 op = (operator == "*" ? 'IN' : 'NOT IN')
839 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
837 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
840 when "=", "!"
838 when "=", "!"
841 op = (operator == "=" ? 'IN' : 'NOT IN')
839 op = (operator == "=" ? 'IN' : 'NOT IN')
842 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
840 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
843 when "=p", "=!p", "!p"
841 when "=p", "=!p", "!p"
844 op = (operator == "!p" ? 'NOT IN' : 'IN')
842 op = (operator == "!p" ? 'NOT IN' : 'IN')
845 comp = (operator == "=!p" ? '<>' : '=')
843 comp = (operator == "=!p" ? '<>' : '=')
846 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
844 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
847 end
845 end
848
846
849 if relation_options[:sym] == field && !options[:reverse]
847 if relation_options[:sym] == field && !options[:reverse]
850 sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
848 sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
851 sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
849 sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
852 else
850 else
853 sql
851 sql
854 end
852 end
855 end
853 end
856
854
857 IssueRelation::TYPES.keys.each do |relation_type|
855 IssueRelation::TYPES.keys.each do |relation_type|
858 alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
856 alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
859 end
857 end
860
858
861 private
859 private
862
860
863 def sql_for_custom_field(field, operator, value, custom_field_id)
861 def sql_for_custom_field(field, operator, value, custom_field_id)
864 db_table = CustomValue.table_name
862 db_table = CustomValue.table_name
865 db_field = 'value'
863 db_field = 'value'
866 filter = @available_filters[field]
864 filter = @available_filters[field]
867 return nil unless filter
865 return nil unless filter
868 if filter[:format] == 'user'
866 if filter[:format] == 'user'
869 if value.delete('me')
867 if value.delete('me')
870 value.push User.current.id.to_s
868 value.push User.current.id.to_s
871 end
869 end
872 end
870 end
873 not_in = nil
871 not_in = nil
874 if operator == '!'
872 if operator == '!'
875 # Makes ! operator work for custom fields with multiple values
873 # Makes ! operator work for custom fields with multiple values
876 operator = '='
874 operator = '='
877 not_in = 'NOT'
875 not_in = 'NOT'
878 end
876 end
879 customized_key = "id"
877 customized_key = "id"
880 customized_class = Issue
878 customized_class = Issue
881 if field =~ /^(.+)\.cf_/
879 if field =~ /^(.+)\.cf_/
882 assoc = $1
880 assoc = $1
883 customized_key = "#{assoc}_id"
881 customized_key = "#{assoc}_id"
884 customized_class = Issue.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
882 customized_class = Issue.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
885 raise "Unknown Issue association #{assoc}" unless customized_class
883 raise "Unknown Issue association #{assoc}" unless customized_class
886 end
884 end
887 "#{Issue.table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
885 "#{Issue.table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
888 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
886 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
889 end
887 end
890
888
891 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
889 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
892 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
890 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
893 sql = ''
891 sql = ''
894 case operator
892 case operator
895 when "="
893 when "="
896 if value.any?
894 if value.any?
897 case type_for(field)
895 case type_for(field)
898 when :date, :date_past
896 when :date, :date_past
899 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
897 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
900 when :integer
898 when :integer
901 if is_custom_filter
899 if is_custom_filter
902 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
900 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
903 else
901 else
904 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
902 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
905 end
903 end
906 when :float
904 when :float
907 if is_custom_filter
905 if is_custom_filter
908 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})"
906 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})"
909 else
907 else
910 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
908 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
911 end
909 end
912 else
910 else
913 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
911 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
914 end
912 end
915 else
913 else
916 # IN an empty set
914 # IN an empty set
917 sql = "1=0"
915 sql = "1=0"
918 end
916 end
919 when "!"
917 when "!"
920 if value.any?
918 if value.any?
921 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
919 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
922 else
920 else
923 # NOT IN an empty set
921 # NOT IN an empty set
924 sql = "1=1"
922 sql = "1=1"
925 end
923 end
926 when "!*"
924 when "!*"
927 sql = "#{db_table}.#{db_field} IS NULL"
925 sql = "#{db_table}.#{db_field} IS NULL"
928 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
926 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
929 when "*"
927 when "*"
930 sql = "#{db_table}.#{db_field} IS NOT NULL"
928 sql = "#{db_table}.#{db_field} IS NOT NULL"
931 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
929 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
932 when ">="
930 when ">="
933 if [:date, :date_past].include?(type_for(field))
931 if [:date, :date_past].include?(type_for(field))
934 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
932 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
935 else
933 else
936 if is_custom_filter
934 if is_custom_filter
937 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
935 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
938 else
936 else
939 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
937 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
940 end
938 end
941 end
939 end
942 when "<="
940 when "<="
943 if [:date, :date_past].include?(type_for(field))
941 if [:date, :date_past].include?(type_for(field))
944 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
942 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
945 else
943 else
946 if is_custom_filter
944 if is_custom_filter
947 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
945 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
948 else
946 else
949 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
947 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
950 end
948 end
951 end
949 end
952 when "><"
950 when "><"
953 if [:date, :date_past].include?(type_for(field))
951 if [:date, :date_past].include?(type_for(field))
954 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
952 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
955 else
953 else
956 if is_custom_filter
954 if is_custom_filter
957 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})"
955 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})"
958 else
956 else
959 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
957 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
960 end
958 end
961 end
959 end
962 when "o"
960 when "o"
963 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
961 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
964 when "c"
962 when "c"
965 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
963 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
966 when "><t-"
964 when "><t-"
967 # between today - n days and today
965 # between today - n days and today
968 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
966 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
969 when ">t-"
967 when ">t-"
970 # >= today - n days
968 # >= today - n days
971 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
969 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
972 when "<t-"
970 when "<t-"
973 # <= today - n days
971 # <= today - n days
974 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
972 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
975 when "t-"
973 when "t-"
976 # = n days in past
974 # = n days in past
977 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
975 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
978 when "><t+"
976 when "><t+"
979 # between today and today + n days
977 # between today and today + n days
980 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
978 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
981 when ">t+"
979 when ">t+"
982 # >= today + n days
980 # >= today + n days
983 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
981 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
984 when "<t+"
982 when "<t+"
985 # <= today + n days
983 # <= today + n days
986 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
984 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
987 when "t+"
985 when "t+"
988 # = today + n days
986 # = today + n days
989 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
987 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
990 when "t"
988 when "t"
991 # = today
989 # = today
992 sql = relative_date_clause(db_table, db_field, 0, 0)
990 sql = relative_date_clause(db_table, db_field, 0, 0)
993 when "w"
991 when "w"
994 # = this week
992 # = this week
995 first_day_of_week = l(:general_first_day_of_week).to_i
993 first_day_of_week = l(:general_first_day_of_week).to_i
996 day_of_week = Date.today.cwday
994 day_of_week = Date.today.cwday
997 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
995 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
998 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
996 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
999 when "~"
997 when "~"
1000 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
998 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
1001 when "!~"
999 when "!~"
1002 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
1000 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
1003 else
1001 else
1004 raise "Unknown query operator #{operator}"
1002 raise "Unknown query operator #{operator}"
1005 end
1003 end
1006
1004
1007 return sql
1005 return sql
1008 end
1006 end
1009
1007
1010 def add_custom_fields_filters(custom_fields, assoc=nil)
1008 def add_custom_fields_filters(custom_fields, assoc=nil)
1011 return unless custom_fields.present?
1009 return unless custom_fields.present?
1012 @available_filters ||= {}
1010 @available_filters ||= {}
1013
1011
1014 custom_fields.select(&:is_filter?).each do |field|
1012 custom_fields.select(&:is_filter?).each do |field|
1015 case field.field_format
1013 case field.field_format
1016 when "text"
1014 when "text"
1017 options = { :type => :text, :order => 20 }
1015 options = { :type => :text, :order => 20 }
1018 when "list"
1016 when "list"
1019 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
1017 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
1020 when "date"
1018 when "date"
1021 options = { :type => :date, :order => 20 }
1019 options = { :type => :date, :order => 20 }
1022 when "bool"
1020 when "bool"
1023 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
1021 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
1024 when "int"
1022 when "int"
1025 options = { :type => :integer, :order => 20 }
1023 options = { :type => :integer, :order => 20 }
1026 when "float"
1024 when "float"
1027 options = { :type => :float, :order => 20 }
1025 options = { :type => :float, :order => 20 }
1028 when "user", "version"
1026 when "user", "version"
1029 next unless project
1027 next unless project
1030 values = field.possible_values_options(project)
1028 values = field.possible_values_options(project)
1031 if User.current.logged? && field.field_format == 'user'
1029 if User.current.logged? && field.field_format == 'user'
1032 values.unshift ["<< #{l(:label_me)} >>", "me"]
1030 values.unshift ["<< #{l(:label_me)} >>", "me"]
1033 end
1031 end
1034 options = { :type => :list_optional, :values => values, :order => 20}
1032 options = { :type => :list_optional, :values => values, :order => 20}
1035 else
1033 else
1036 options = { :type => :string, :order => 20 }
1034 options = { :type => :string, :order => 20 }
1037 end
1035 end
1038 filter_id = "cf_#{field.id}"
1036 filter_id = "cf_#{field.id}"
1039 filter_name = field.name
1037 filter_name = field.name
1040 if assoc.present?
1038 if assoc.present?
1041 filter_id = "#{assoc}.#{filter_id}"
1039 filter_id = "#{assoc}.#{filter_id}"
1042 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1040 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1043 end
1041 end
1044 @available_filters[filter_id] = options.merge({
1042 @available_filters[filter_id] = options.merge({
1045 :name => filter_name,
1043 :name => filter_name,
1046 :format => field.field_format,
1044 :format => field.field_format,
1047 :field => field
1045 :field => field
1048 })
1046 })
1049 end
1047 end
1050 end
1048 end
1051
1049
1052 def add_associations_custom_fields_filters(*associations)
1050 def add_associations_custom_fields_filters(*associations)
1053 fields_by_class = CustomField.where(:is_filter => true).group_by(&:class)
1051 fields_by_class = CustomField.where(:is_filter => true).group_by(&:class)
1054 associations.each do |assoc|
1052 associations.each do |assoc|
1055 association_klass = Issue.reflect_on_association(assoc).klass
1053 association_klass = Issue.reflect_on_association(assoc).klass
1056 fields_by_class.each do |field_class, fields|
1054 fields_by_class.each do |field_class, fields|
1057 if field_class.customized_class <= association_klass
1055 if field_class.customized_class <= association_klass
1058 add_custom_fields_filters(fields, assoc)
1056 add_custom_fields_filters(fields, assoc)
1059 end
1057 end
1060 end
1058 end
1061 end
1059 end
1062 end
1060 end
1063
1061
1064 # Returns a SQL clause for a date or datetime field.
1062 # Returns a SQL clause for a date or datetime field.
1065 def date_clause(table, field, from, to)
1063 def date_clause(table, field, from, to)
1066 s = []
1064 s = []
1067 if from
1065 if from
1068 from_yesterday = from - 1
1066 from_yesterday = from - 1
1069 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
1067 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
1070 if self.class.default_timezone == :utc
1068 if self.class.default_timezone == :utc
1071 from_yesterday_time = from_yesterday_time.utc
1069 from_yesterday_time = from_yesterday_time.utc
1072 end
1070 end
1073 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
1071 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
1074 end
1072 end
1075 if to
1073 if to
1076 to_time = Time.local(to.year, to.month, to.day)
1074 to_time = Time.local(to.year, to.month, to.day)
1077 if self.class.default_timezone == :utc
1075 if self.class.default_timezone == :utc
1078 to_time = to_time.utc
1076 to_time = to_time.utc
1079 end
1077 end
1080 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
1078 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
1081 end
1079 end
1082 s.join(' AND ')
1080 s.join(' AND ')
1083 end
1081 end
1084
1082
1085 # Returns a SQL clause for a date or datetime field using relative dates.
1083 # Returns a SQL clause for a date or datetime field using relative dates.
1086 def relative_date_clause(table, field, days_from, days_to)
1084 def relative_date_clause(table, field, days_from, days_to)
1087 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
1085 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
1088 end
1086 end
1089
1087
1090 # Additional joins required for the given sort options
1088 # Additional joins required for the given sort options
1091 def joins_for_order_statement(order_options)
1089 def joins_for_order_statement(order_options)
1092 joins = []
1090 joins = []
1093
1091
1094 if order_options
1092 if order_options
1095 if order_options.include?('authors')
1093 if order_options.include?('authors')
1096 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{Issue.table_name}.author_id"
1094 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{Issue.table_name}.author_id"
1097 end
1095 end
1098 order_options.scan(/cf_\d+/).uniq.each do |name|
1096 order_options.scan(/cf_\d+/).uniq.each do |name|
1099 column = available_columns.detect {|c| c.name.to_s == name}
1097 column = available_columns.detect {|c| c.name.to_s == name}
1100 join = column && column.custom_field.join_for_order_statement
1098 join = column && column.custom_field.join_for_order_statement
1101 if join
1099 if join
1102 joins << join
1100 joins << join
1103 end
1101 end
1104 end
1102 end
1105 end
1103 end
1106
1104
1107 joins.any? ? joins.join(' ') : nil
1105 joins.any? ? joins.join(' ') : nil
1108 end
1106 end
1109 end
1107 end
@@ -1,120 +1,117
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 scope :visible, lambda {|*args| {
45 scope :visible, lambda {|*args|
46 :include => :project,
46 includes(:project).where(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)
47 }
48 }}
48 scope :on_issue, lambda {|issue|
49 scope :on_issue, lambda {|issue| {
49 includes(:issue).where("#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}")
50 :include => :issue,
50 }
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 scope :on_project, lambda {|project, include_subprojects|
52 }}
52 includes(:project).where(project.project_condition(include_subprojects))
53 scope :on_project, lambda {|project, include_subprojects| {
53 }
54 :include => :project,
55 :conditions => project.project_condition(include_subprojects)
56 }}
57 scope :spent_between, lambda {|from, to|
54 scope :spent_between, lambda {|from, to|
58 if from && to
55 if from && to
59 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
56 where("#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to)
60 elsif from
57 elsif from
61 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
58 where("#{TimeEntry.table_name}.spent_on >= ?", from)
62 elsif to
59 elsif to
63 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
60 where("#{TimeEntry.table_name}.spent_on <= ?", to)
64 else
61 else
65 {}
62 where(nil)
66 end
63 end
67 }
64 }
68
65
69 safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
66 safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
70
67
71 def initialize(attributes=nil, *args)
68 def initialize(attributes=nil, *args)
72 super
69 super
73 if new_record? && self.activity.nil?
70 if new_record? && self.activity.nil?
74 if default_activity = TimeEntryActivity.default
71 if default_activity = TimeEntryActivity.default
75 self.activity_id = default_activity.id
72 self.activity_id = default_activity.id
76 end
73 end
77 self.hours = nil if hours == 0
74 self.hours = nil if hours == 0
78 end
75 end
79 end
76 end
80
77
81 def set_project_if_nil
78 def set_project_if_nil
82 self.project = issue.project if issue && project.nil?
79 self.project = issue.project if issue && project.nil?
83 end
80 end
84
81
85 def validate_time_entry
82 def validate_time_entry
86 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
83 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
87 errors.add :project_id, :invalid if project.nil?
84 errors.add :project_id, :invalid if project.nil?
88 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
85 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
89 end
86 end
90
87
91 def hours=(h)
88 def hours=(h)
92 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
89 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
93 end
90 end
94
91
95 def hours
92 def hours
96 h = read_attribute(:hours)
93 h = read_attribute(:hours)
97 if h.is_a?(Float)
94 if h.is_a?(Float)
98 h.round(2)
95 h.round(2)
99 else
96 else
100 h
97 h
101 end
98 end
102 end
99 end
103
100
104 # tyear, tmonth, tweek assigned where setting spent_on attributes
101 # tyear, tmonth, tweek assigned where setting spent_on attributes
105 # these attributes make time aggregations easier
102 # these attributes make time aggregations easier
106 def spent_on=(date)
103 def spent_on=(date)
107 super
104 super
108 if spent_on.is_a?(Time)
105 if spent_on.is_a?(Time)
109 self.spent_on = spent_on.to_date
106 self.spent_on = spent_on.to_date
110 end
107 end
111 self.tyear = spent_on ? spent_on.year : nil
108 self.tyear = spent_on ? spent_on.year : nil
112 self.tmonth = spent_on ? spent_on.month : nil
109 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
110 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
114 end
111 end
115
112
116 # Returns true if the time entry can be edited by usr, otherwise false
113 # Returns true if the time entry can be edited by usr, otherwise false
117 def editable_by?(usr)
114 def editable_by?(usr)
118 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
115 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
119 end
116 end
120 end
117 end
@@ -1,712 +1,712
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 => {
31 :firstname_lastname => {
32 :string => '#{firstname} #{lastname}',
32 :string => '#{firstname} #{lastname}',
33 :order => %w(firstname lastname id),
33 :order => %w(firstname lastname id),
34 :setting_order => 1
34 :setting_order => 1
35 },
35 },
36 :firstname_lastinitial => {
36 :firstname_lastinitial => {
37 :string => '#{firstname} #{lastname.to_s.chars.first}.',
37 :string => '#{firstname} #{lastname.to_s.chars.first}.',
38 :order => %w(firstname lastname id),
38 :order => %w(firstname lastname id),
39 :setting_order => 2
39 :setting_order => 2
40 },
40 },
41 :firstname => {
41 :firstname => {
42 :string => '#{firstname}',
42 :string => '#{firstname}',
43 :order => %w(firstname id),
43 :order => %w(firstname id),
44 :setting_order => 3
44 :setting_order => 3
45 },
45 },
46 :lastname_firstname => {
46 :lastname_firstname => {
47 :string => '#{lastname} #{firstname}',
47 :string => '#{lastname} #{firstname}',
48 :order => %w(lastname firstname id),
48 :order => %w(lastname firstname id),
49 :setting_order => 4
49 :setting_order => 4
50 },
50 },
51 :lastname_coma_firstname => {
51 :lastname_coma_firstname => {
52 :string => '#{lastname}, #{firstname}',
52 :string => '#{lastname}, #{firstname}',
53 :order => %w(lastname firstname id),
53 :order => %w(lastname firstname id),
54 :setting_order => 5
54 :setting_order => 5
55 },
55 },
56 :lastname => {
56 :lastname => {
57 :string => '#{lastname}',
57 :string => '#{lastname}',
58 :order => %w(lastname id),
58 :order => %w(lastname id),
59 :setting_order => 6
59 :setting_order => 6
60 },
60 },
61 :username => {
61 :username => {
62 :string => '#{login}',
62 :string => '#{login}',
63 :order => %w(login id),
63 :order => %w(login id),
64 :setting_order => 7
64 :setting_order => 7
65 },
65 },
66 }
66 }
67
67
68 MAIL_NOTIFICATION_OPTIONS = [
68 MAIL_NOTIFICATION_OPTIONS = [
69 ['all', :label_user_mail_option_all],
69 ['all', :label_user_mail_option_all],
70 ['selected', :label_user_mail_option_selected],
70 ['selected', :label_user_mail_option_selected],
71 ['only_my_events', :label_user_mail_option_only_my_events],
71 ['only_my_events', :label_user_mail_option_only_my_events],
72 ['only_assigned', :label_user_mail_option_only_assigned],
72 ['only_assigned', :label_user_mail_option_only_assigned],
73 ['only_owner', :label_user_mail_option_only_owner],
73 ['only_owner', :label_user_mail_option_only_owner],
74 ['none', :label_user_mail_option_none]
74 ['none', :label_user_mail_option_none]
75 ]
75 ]
76
76
77 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
77 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
78 :after_remove => Proc.new {|user, group| group.user_removed(user)}
78 :after_remove => Proc.new {|user, group| group.user_removed(user)}
79 has_many :changesets, :dependent => :nullify
79 has_many :changesets, :dependent => :nullify
80 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
80 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
81 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
81 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
82 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
82 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
83 belongs_to :auth_source
83 belongs_to :auth_source
84
84
85 scope :logged, lambda { { :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}" } }
85 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
86 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
86 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
87
87
88 acts_as_customizable
88 acts_as_customizable
89
89
90 attr_accessor :password, :password_confirmation
90 attr_accessor :password, :password_confirmation
91 attr_accessor :last_before_login_on
91 attr_accessor :last_before_login_on
92 # Prevents unauthorized assignments
92 # Prevents unauthorized assignments
93 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
93 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
94
94
95 LOGIN_LENGTH_LIMIT = 60
95 LOGIN_LENGTH_LIMIT = 60
96 MAIL_LENGTH_LIMIT = 60
96 MAIL_LENGTH_LIMIT = 60
97
97
98 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
98 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
99 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
99 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
100 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
100 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
101 # Login must contain lettres, numbers, underscores only
101 # Login must contain lettres, numbers, underscores only
102 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
102 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
103 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
103 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
104 validates_length_of :firstname, :lastname, :maximum => 30
104 validates_length_of :firstname, :lastname, :maximum => 30
105 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
105 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
106 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
106 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
107 validates_confirmation_of :password, :allow_nil => true
107 validates_confirmation_of :password, :allow_nil => true
108 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
108 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
109 validate :validate_password_length
109 validate :validate_password_length
110
110
111 before_create :set_mail_notification
111 before_create :set_mail_notification
112 before_save :update_hashed_password
112 before_save :update_hashed_password
113 before_destroy :remove_references_before_destroy
113 before_destroy :remove_references_before_destroy
114
114
115 scope :in_group, lambda {|group|
115 scope :in_group, lambda {|group|
116 group_id = group.is_a?(Group) ? group.id : group.to_i
116 group_id = group.is_a?(Group) ? group.id : group.to_i
117 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
117 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
118 }
118 }
119 scope :not_in_group, lambda {|group|
119 scope :not_in_group, lambda {|group|
120 group_id = group.is_a?(Group) ? group.id : group.to_i
120 group_id = group.is_a?(Group) ? group.id : group.to_i
121 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
121 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
122 }
122 }
123
123
124 def set_mail_notification
124 def set_mail_notification
125 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
125 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
126 true
126 true
127 end
127 end
128
128
129 def update_hashed_password
129 def update_hashed_password
130 # update hashed_password if password was set
130 # update hashed_password if password was set
131 if self.password && self.auth_source_id.blank?
131 if self.password && self.auth_source_id.blank?
132 salt_password(password)
132 salt_password(password)
133 end
133 end
134 end
134 end
135
135
136 def reload(*args)
136 def reload(*args)
137 @name = nil
137 @name = nil
138 @projects_by_role = nil
138 @projects_by_role = nil
139 super
139 super
140 end
140 end
141
141
142 def mail=(arg)
142 def mail=(arg)
143 write_attribute(:mail, arg.to_s.strip)
143 write_attribute(:mail, arg.to_s.strip)
144 end
144 end
145
145
146 def identity_url=(url)
146 def identity_url=(url)
147 if url.blank?
147 if url.blank?
148 write_attribute(:identity_url, '')
148 write_attribute(:identity_url, '')
149 else
149 else
150 begin
150 begin
151 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
151 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
152 rescue OpenIdAuthentication::InvalidOpenId
152 rescue OpenIdAuthentication::InvalidOpenId
153 # Invlaid url, don't save
153 # Invlaid url, don't save
154 end
154 end
155 end
155 end
156 self.read_attribute(:identity_url)
156 self.read_attribute(:identity_url)
157 end
157 end
158
158
159 # Returns the user that matches provided login and password, or nil
159 # Returns the user that matches provided login and password, or nil
160 def self.try_to_login(login, password)
160 def self.try_to_login(login, password)
161 login = login.to_s
161 login = login.to_s
162 password = password.to_s
162 password = password.to_s
163
163
164 # Make sure no one can sign in with an empty password
164 # Make sure no one can sign in with an empty password
165 return nil if password.empty?
165 return nil if password.empty?
166 user = find_by_login(login)
166 user = find_by_login(login)
167 if user
167 if user
168 # user is already in local database
168 # user is already in local database
169 return nil if !user.active?
169 return nil if !user.active?
170 if user.auth_source
170 if user.auth_source
171 # user has an external authentication method
171 # user has an external authentication method
172 return nil unless user.auth_source.authenticate(login, password)
172 return nil unless user.auth_source.authenticate(login, password)
173 else
173 else
174 # authentication with local password
174 # authentication with local password
175 return nil unless user.check_password?(password)
175 return nil unless user.check_password?(password)
176 end
176 end
177 else
177 else
178 # user is not yet registered, try to authenticate with available sources
178 # user is not yet registered, try to authenticate with available sources
179 attrs = AuthSource.authenticate(login, password)
179 attrs = AuthSource.authenticate(login, password)
180 if attrs
180 if attrs
181 user = new(attrs)
181 user = new(attrs)
182 user.login = login
182 user.login = login
183 user.language = Setting.default_language
183 user.language = Setting.default_language
184 if user.save
184 if user.save
185 user.reload
185 user.reload
186 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
186 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
187 end
187 end
188 end
188 end
189 end
189 end
190 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
190 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
191 user
191 user
192 rescue => text
192 rescue => text
193 raise text
193 raise text
194 end
194 end
195
195
196 # Returns the user who matches the given autologin +key+ or nil
196 # Returns the user who matches the given autologin +key+ or nil
197 def self.try_to_autologin(key)
197 def self.try_to_autologin(key)
198 tokens = Token.find_all_by_action_and_value('autologin', key.to_s)
198 tokens = Token.find_all_by_action_and_value('autologin', key.to_s)
199 # Make sure there's only 1 token that matches the key
199 # Make sure there's only 1 token that matches the key
200 if tokens.size == 1
200 if tokens.size == 1
201 token = tokens.first
201 token = tokens.first
202 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
202 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
203 token.user.update_attribute(:last_login_on, Time.now)
203 token.user.update_attribute(:last_login_on, Time.now)
204 token.user
204 token.user
205 end
205 end
206 end
206 end
207 end
207 end
208
208
209 def self.name_formatter(formatter = nil)
209 def self.name_formatter(formatter = nil)
210 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
210 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
211 end
211 end
212
212
213 # Returns an array of fields names than can be used to make an order statement for users
213 # Returns an array of fields names than can be used to make an order statement for users
214 # according to how user names are displayed
214 # according to how user names are displayed
215 # Examples:
215 # Examples:
216 #
216 #
217 # User.fields_for_order_statement => ['users.login', 'users.id']
217 # User.fields_for_order_statement => ['users.login', 'users.id']
218 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
218 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
219 def self.fields_for_order_statement(table=nil)
219 def self.fields_for_order_statement(table=nil)
220 table ||= table_name
220 table ||= table_name
221 name_formatter[:order].map {|field| "#{table}.#{field}"}
221 name_formatter[:order].map {|field| "#{table}.#{field}"}
222 end
222 end
223
223
224 # Return user's full name for display
224 # Return user's full name for display
225 def name(formatter = nil)
225 def name(formatter = nil)
226 f = self.class.name_formatter(formatter)
226 f = self.class.name_formatter(formatter)
227 if formatter
227 if formatter
228 eval('"' + f[:string] + '"')
228 eval('"' + f[:string] + '"')
229 else
229 else
230 @name ||= eval('"' + f[:string] + '"')
230 @name ||= eval('"' + f[:string] + '"')
231 end
231 end
232 end
232 end
233
233
234 def active?
234 def active?
235 self.status == STATUS_ACTIVE
235 self.status == STATUS_ACTIVE
236 end
236 end
237
237
238 def registered?
238 def registered?
239 self.status == STATUS_REGISTERED
239 self.status == STATUS_REGISTERED
240 end
240 end
241
241
242 def locked?
242 def locked?
243 self.status == STATUS_LOCKED
243 self.status == STATUS_LOCKED
244 end
244 end
245
245
246 def activate
246 def activate
247 self.status = STATUS_ACTIVE
247 self.status = STATUS_ACTIVE
248 end
248 end
249
249
250 def register
250 def register
251 self.status = STATUS_REGISTERED
251 self.status = STATUS_REGISTERED
252 end
252 end
253
253
254 def lock
254 def lock
255 self.status = STATUS_LOCKED
255 self.status = STATUS_LOCKED
256 end
256 end
257
257
258 def activate!
258 def activate!
259 update_attribute(:status, STATUS_ACTIVE)
259 update_attribute(:status, STATUS_ACTIVE)
260 end
260 end
261
261
262 def register!
262 def register!
263 update_attribute(:status, STATUS_REGISTERED)
263 update_attribute(:status, STATUS_REGISTERED)
264 end
264 end
265
265
266 def lock!
266 def lock!
267 update_attribute(:status, STATUS_LOCKED)
267 update_attribute(:status, STATUS_LOCKED)
268 end
268 end
269
269
270 # Returns true if +clear_password+ is the correct user's password, otherwise false
270 # Returns true if +clear_password+ is the correct user's password, otherwise false
271 def check_password?(clear_password)
271 def check_password?(clear_password)
272 if auth_source_id.present?
272 if auth_source_id.present?
273 auth_source.authenticate(self.login, clear_password)
273 auth_source.authenticate(self.login, clear_password)
274 else
274 else
275 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
275 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
276 end
276 end
277 end
277 end
278
278
279 # Generates a random salt and computes hashed_password for +clear_password+
279 # Generates a random salt and computes hashed_password for +clear_password+
280 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
280 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
281 def salt_password(clear_password)
281 def salt_password(clear_password)
282 self.salt = User.generate_salt
282 self.salt = User.generate_salt
283 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
283 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
284 end
284 end
285
285
286 # Does the backend storage allow this user to change their password?
286 # Does the backend storage allow this user to change their password?
287 def change_password_allowed?
287 def change_password_allowed?
288 return true if auth_source.nil?
288 return true if auth_source.nil?
289 return auth_source.allow_password_changes?
289 return auth_source.allow_password_changes?
290 end
290 end
291
291
292 # Generate and set a random password. Useful for automated user creation
292 # Generate and set a random password. Useful for automated user creation
293 # Based on Token#generate_token_value
293 # Based on Token#generate_token_value
294 #
294 #
295 def random_password
295 def random_password
296 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
296 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
297 password = ''
297 password = ''
298 40.times { |i| password << chars[rand(chars.size-1)] }
298 40.times { |i| password << chars[rand(chars.size-1)] }
299 self.password = password
299 self.password = password
300 self.password_confirmation = password
300 self.password_confirmation = password
301 self
301 self
302 end
302 end
303
303
304 def pref
304 def pref
305 self.preference ||= UserPreference.new(:user => self)
305 self.preference ||= UserPreference.new(:user => self)
306 end
306 end
307
307
308 def time_zone
308 def time_zone
309 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
309 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
310 end
310 end
311
311
312 def wants_comments_in_reverse_order?
312 def wants_comments_in_reverse_order?
313 self.pref[:comments_sorting] == 'desc'
313 self.pref[:comments_sorting] == 'desc'
314 end
314 end
315
315
316 # Return user's RSS key (a 40 chars long string), used to access feeds
316 # Return user's RSS key (a 40 chars long string), used to access feeds
317 def rss_key
317 def rss_key
318 if rss_token.nil?
318 if rss_token.nil?
319 create_rss_token(:action => 'feeds')
319 create_rss_token(:action => 'feeds')
320 end
320 end
321 rss_token.value
321 rss_token.value
322 end
322 end
323
323
324 # Return user's API key (a 40 chars long string), used to access the API
324 # Return user's API key (a 40 chars long string), used to access the API
325 def api_key
325 def api_key
326 if api_token.nil?
326 if api_token.nil?
327 create_api_token(:action => 'api')
327 create_api_token(:action => 'api')
328 end
328 end
329 api_token.value
329 api_token.value
330 end
330 end
331
331
332 # Return an array of project ids for which the user has explicitly turned mail notifications on
332 # Return an array of project ids for which the user has explicitly turned mail notifications on
333 def notified_projects_ids
333 def notified_projects_ids
334 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
334 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
335 end
335 end
336
336
337 def notified_project_ids=(ids)
337 def notified_project_ids=(ids)
338 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
338 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
339 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
339 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
340 @notified_projects_ids = nil
340 @notified_projects_ids = nil
341 notified_projects_ids
341 notified_projects_ids
342 end
342 end
343
343
344 def valid_notification_options
344 def valid_notification_options
345 self.class.valid_notification_options(self)
345 self.class.valid_notification_options(self)
346 end
346 end
347
347
348 # Only users that belong to more than 1 project can select projects for which they are notified
348 # Only users that belong to more than 1 project can select projects for which they are notified
349 def self.valid_notification_options(user=nil)
349 def self.valid_notification_options(user=nil)
350 # Note that @user.membership.size would fail since AR ignores
350 # Note that @user.membership.size would fail since AR ignores
351 # :include association option when doing a count
351 # :include association option when doing a count
352 if user.nil? || user.memberships.length < 1
352 if user.nil? || user.memberships.length < 1
353 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
353 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
354 else
354 else
355 MAIL_NOTIFICATION_OPTIONS
355 MAIL_NOTIFICATION_OPTIONS
356 end
356 end
357 end
357 end
358
358
359 # Find a user account by matching the exact login and then a case-insensitive
359 # Find a user account by matching the exact login and then a case-insensitive
360 # version. Exact matches will be given priority.
360 # version. Exact matches will be given priority.
361 def self.find_by_login(login)
361 def self.find_by_login(login)
362 # First look for an exact match
362 # First look for an exact match
363 user = where(:login => login).all.detect {|u| u.login == login}
363 user = where(:login => login).all.detect {|u| u.login == login}
364 unless user
364 unless user
365 # Fail over to case-insensitive if none was found
365 # Fail over to case-insensitive if none was found
366 user = where("LOWER(login) = ?", login.to_s.downcase).first
366 user = where("LOWER(login) = ?", login.to_s.downcase).first
367 end
367 end
368 user
368 user
369 end
369 end
370
370
371 def self.find_by_rss_key(key)
371 def self.find_by_rss_key(key)
372 token = Token.find_by_action_and_value('feeds', key.to_s)
372 token = Token.find_by_action_and_value('feeds', key.to_s)
373 token && token.user.active? ? token.user : nil
373 token && token.user.active? ? token.user : nil
374 end
374 end
375
375
376 def self.find_by_api_key(key)
376 def self.find_by_api_key(key)
377 token = Token.find_by_action_and_value('api', key.to_s)
377 token = Token.find_by_action_and_value('api', key.to_s)
378 token && token.user.active? ? token.user : nil
378 token && token.user.active? ? token.user : nil
379 end
379 end
380
380
381 # Makes find_by_mail case-insensitive
381 # Makes find_by_mail case-insensitive
382 def self.find_by_mail(mail)
382 def self.find_by_mail(mail)
383 where("LOWER(mail) = ?", mail.to_s.downcase).first
383 where("LOWER(mail) = ?", mail.to_s.downcase).first
384 end
384 end
385
385
386 # Returns true if the default admin account can no longer be used
386 # Returns true if the default admin account can no longer be used
387 def self.default_admin_account_changed?
387 def self.default_admin_account_changed?
388 !User.active.find_by_login("admin").try(:check_password?, "admin")
388 !User.active.find_by_login("admin").try(:check_password?, "admin")
389 end
389 end
390
390
391 def to_s
391 def to_s
392 name
392 name
393 end
393 end
394
394
395 CSS_CLASS_BY_STATUS = {
395 CSS_CLASS_BY_STATUS = {
396 STATUS_ANONYMOUS => 'anon',
396 STATUS_ANONYMOUS => 'anon',
397 STATUS_ACTIVE => 'active',
397 STATUS_ACTIVE => 'active',
398 STATUS_REGISTERED => 'registered',
398 STATUS_REGISTERED => 'registered',
399 STATUS_LOCKED => 'locked'
399 STATUS_LOCKED => 'locked'
400 }
400 }
401
401
402 def css_classes
402 def css_classes
403 "user #{CSS_CLASS_BY_STATUS[status]}"
403 "user #{CSS_CLASS_BY_STATUS[status]}"
404 end
404 end
405
405
406 # Returns the current day according to user's time zone
406 # Returns the current day according to user's time zone
407 def today
407 def today
408 if time_zone.nil?
408 if time_zone.nil?
409 Date.today
409 Date.today
410 else
410 else
411 Time.now.in_time_zone(time_zone).to_date
411 Time.now.in_time_zone(time_zone).to_date
412 end
412 end
413 end
413 end
414
414
415 # Returns the day of +time+ according to user's time zone
415 # Returns the day of +time+ according to user's time zone
416 def time_to_date(time)
416 def time_to_date(time)
417 if time_zone.nil?
417 if time_zone.nil?
418 time.to_date
418 time.to_date
419 else
419 else
420 time.in_time_zone(time_zone).to_date
420 time.in_time_zone(time_zone).to_date
421 end
421 end
422 end
422 end
423
423
424 def logged?
424 def logged?
425 true
425 true
426 end
426 end
427
427
428 def anonymous?
428 def anonymous?
429 !logged?
429 !logged?
430 end
430 end
431
431
432 # Return user's roles for project
432 # Return user's roles for project
433 def roles_for_project(project)
433 def roles_for_project(project)
434 roles = []
434 roles = []
435 # No role on archived projects
435 # No role on archived projects
436 return roles if project.nil? || project.archived?
436 return roles if project.nil? || project.archived?
437 if logged?
437 if logged?
438 # Find project membership
438 # Find project membership
439 membership = memberships.detect {|m| m.project_id == project.id}
439 membership = memberships.detect {|m| m.project_id == project.id}
440 if membership
440 if membership
441 roles = membership.roles
441 roles = membership.roles
442 else
442 else
443 @role_non_member ||= Role.non_member
443 @role_non_member ||= Role.non_member
444 roles << @role_non_member
444 roles << @role_non_member
445 end
445 end
446 else
446 else
447 @role_anonymous ||= Role.anonymous
447 @role_anonymous ||= Role.anonymous
448 roles << @role_anonymous
448 roles << @role_anonymous
449 end
449 end
450 roles
450 roles
451 end
451 end
452
452
453 # Return true if the user is a member of project
453 # Return true if the user is a member of project
454 def member_of?(project)
454 def member_of?(project)
455 !roles_for_project(project).detect {|role| role.member?}.nil?
455 !roles_for_project(project).detect {|role| role.member?}.nil?
456 end
456 end
457
457
458 # Returns a hash of user's projects grouped by roles
458 # Returns a hash of user's projects grouped by roles
459 def projects_by_role
459 def projects_by_role
460 return @projects_by_role if @projects_by_role
460 return @projects_by_role if @projects_by_role
461
461
462 @projects_by_role = Hash.new([])
462 @projects_by_role = Hash.new([])
463 memberships.each do |membership|
463 memberships.each do |membership|
464 if membership.project
464 if membership.project
465 membership.roles.each do |role|
465 membership.roles.each do |role|
466 @projects_by_role[role] = [] unless @projects_by_role.key?(role)
466 @projects_by_role[role] = [] unless @projects_by_role.key?(role)
467 @projects_by_role[role] << membership.project
467 @projects_by_role[role] << membership.project
468 end
468 end
469 end
469 end
470 end
470 end
471 @projects_by_role.each do |role, projects|
471 @projects_by_role.each do |role, projects|
472 projects.uniq!
472 projects.uniq!
473 end
473 end
474
474
475 @projects_by_role
475 @projects_by_role
476 end
476 end
477
477
478 # Returns true if user is arg or belongs to arg
478 # Returns true if user is arg or belongs to arg
479 def is_or_belongs_to?(arg)
479 def is_or_belongs_to?(arg)
480 if arg.is_a?(User)
480 if arg.is_a?(User)
481 self == arg
481 self == arg
482 elsif arg.is_a?(Group)
482 elsif arg.is_a?(Group)
483 arg.users.include?(self)
483 arg.users.include?(self)
484 else
484 else
485 false
485 false
486 end
486 end
487 end
487 end
488
488
489 # Return true if the user is allowed to do the specified action on a specific context
489 # Return true if the user is allowed to do the specified action on a specific context
490 # Action can be:
490 # Action can be:
491 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
491 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
492 # * a permission Symbol (eg. :edit_project)
492 # * a permission Symbol (eg. :edit_project)
493 # Context can be:
493 # Context can be:
494 # * a project : returns true if user is allowed to do the specified action on this project
494 # * a project : returns true if user is allowed to do the specified action on this project
495 # * an array of projects : returns true if user is allowed on every project
495 # * an array of projects : returns true if user is allowed on every project
496 # * nil with options[:global] set : check if user has at least one role allowed for this action,
496 # * nil with options[:global] set : check if user has at least one role allowed for this action,
497 # or falls back to Non Member / Anonymous permissions depending if the user is logged
497 # or falls back to Non Member / Anonymous permissions depending if the user is logged
498 def allowed_to?(action, context, options={}, &block)
498 def allowed_to?(action, context, options={}, &block)
499 if context && context.is_a?(Project)
499 if context && context.is_a?(Project)
500 return false unless context.allows_to?(action)
500 return false unless context.allows_to?(action)
501 # Admin users are authorized for anything else
501 # Admin users are authorized for anything else
502 return true if admin?
502 return true if admin?
503
503
504 roles = roles_for_project(context)
504 roles = roles_for_project(context)
505 return false unless roles
505 return false unless roles
506 roles.any? {|role|
506 roles.any? {|role|
507 (context.is_public? || role.member?) &&
507 (context.is_public? || role.member?) &&
508 role.allowed_to?(action) &&
508 role.allowed_to?(action) &&
509 (block_given? ? yield(role, self) : true)
509 (block_given? ? yield(role, self) : true)
510 }
510 }
511 elsif context && context.is_a?(Array)
511 elsif context && context.is_a?(Array)
512 if context.empty?
512 if context.empty?
513 false
513 false
514 else
514 else
515 # Authorize if user is authorized on every element of the array
515 # Authorize if user is authorized on every element of the array
516 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
516 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
517 end
517 end
518 elsif options[:global]
518 elsif options[:global]
519 # Admin users are always authorized
519 # Admin users are always authorized
520 return true if admin?
520 return true if admin?
521
521
522 # authorize if user has at least one role that has this permission
522 # authorize if user has at least one role that has this permission
523 roles = memberships.collect {|m| m.roles}.flatten.uniq
523 roles = memberships.collect {|m| m.roles}.flatten.uniq
524 roles << (self.logged? ? Role.non_member : Role.anonymous)
524 roles << (self.logged? ? Role.non_member : Role.anonymous)
525 roles.any? {|role|
525 roles.any? {|role|
526 role.allowed_to?(action) &&
526 role.allowed_to?(action) &&
527 (block_given? ? yield(role, self) : true)
527 (block_given? ? yield(role, self) : true)
528 }
528 }
529 else
529 else
530 false
530 false
531 end
531 end
532 end
532 end
533
533
534 # Is the user allowed to do the specified action on any project?
534 # Is the user allowed to do the specified action on any project?
535 # See allowed_to? for the actions and valid options.
535 # See allowed_to? for the actions and valid options.
536 def allowed_to_globally?(action, options, &block)
536 def allowed_to_globally?(action, options, &block)
537 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
537 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
538 end
538 end
539
539
540 # Returns true if the user is allowed to delete his own account
540 # Returns true if the user is allowed to delete his own account
541 def own_account_deletable?
541 def own_account_deletable?
542 Setting.unsubscribe? &&
542 Setting.unsubscribe? &&
543 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
543 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
544 end
544 end
545
545
546 safe_attributes 'login',
546 safe_attributes 'login',
547 'firstname',
547 'firstname',
548 'lastname',
548 'lastname',
549 'mail',
549 'mail',
550 'mail_notification',
550 'mail_notification',
551 'language',
551 'language',
552 'custom_field_values',
552 'custom_field_values',
553 'custom_fields',
553 'custom_fields',
554 'identity_url'
554 'identity_url'
555
555
556 safe_attributes 'status',
556 safe_attributes 'status',
557 'auth_source_id',
557 'auth_source_id',
558 :if => lambda {|user, current_user| current_user.admin?}
558 :if => lambda {|user, current_user| current_user.admin?}
559
559
560 safe_attributes 'group_ids',
560 safe_attributes 'group_ids',
561 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
561 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
562
562
563 # Utility method to help check if a user should be notified about an
563 # Utility method to help check if a user should be notified about an
564 # event.
564 # event.
565 #
565 #
566 # TODO: only supports Issue events currently
566 # TODO: only supports Issue events currently
567 def notify_about?(object)
567 def notify_about?(object)
568 case mail_notification
568 case mail_notification
569 when 'all'
569 when 'all'
570 true
570 true
571 when 'selected'
571 when 'selected'
572 # user receives notifications for created/assigned issues on unselected projects
572 # user receives notifications for created/assigned issues on unselected projects
573 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
573 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
574 true
574 true
575 else
575 else
576 false
576 false
577 end
577 end
578 when 'none'
578 when 'none'
579 false
579 false
580 when 'only_my_events'
580 when 'only_my_events'
581 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
581 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
582 true
582 true
583 else
583 else
584 false
584 false
585 end
585 end
586 when 'only_assigned'
586 when 'only_assigned'
587 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
587 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
588 true
588 true
589 else
589 else
590 false
590 false
591 end
591 end
592 when 'only_owner'
592 when 'only_owner'
593 if object.is_a?(Issue) && object.author == self
593 if object.is_a?(Issue) && object.author == self
594 true
594 true
595 else
595 else
596 false
596 false
597 end
597 end
598 else
598 else
599 false
599 false
600 end
600 end
601 end
601 end
602
602
603 def self.current=(user)
603 def self.current=(user)
604 Thread.current[:current_user] = user
604 Thread.current[:current_user] = user
605 end
605 end
606
606
607 def self.current
607 def self.current
608 Thread.current[:current_user] ||= User.anonymous
608 Thread.current[:current_user] ||= User.anonymous
609 end
609 end
610
610
611 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
611 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
612 # one anonymous user per database.
612 # one anonymous user per database.
613 def self.anonymous
613 def self.anonymous
614 anonymous_user = AnonymousUser.first
614 anonymous_user = AnonymousUser.first
615 if anonymous_user.nil?
615 if anonymous_user.nil?
616 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
616 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
617 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
617 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
618 end
618 end
619 anonymous_user
619 anonymous_user
620 end
620 end
621
621
622 # Salts all existing unsalted passwords
622 # Salts all existing unsalted passwords
623 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
623 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
624 # This method is used in the SaltPasswords migration and is to be kept as is
624 # This method is used in the SaltPasswords migration and is to be kept as is
625 def self.salt_unsalted_passwords!
625 def self.salt_unsalted_passwords!
626 transaction do
626 transaction do
627 User.where("salt IS NULL OR salt = ''").find_each do |user|
627 User.where("salt IS NULL OR salt = ''").find_each do |user|
628 next if user.hashed_password.blank?
628 next if user.hashed_password.blank?
629 salt = User.generate_salt
629 salt = User.generate_salt
630 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
630 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
631 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
631 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
632 end
632 end
633 end
633 end
634 end
634 end
635
635
636 protected
636 protected
637
637
638 def validate_password_length
638 def validate_password_length
639 # Password length validation based on setting
639 # Password length validation based on setting
640 if !password.nil? && password.size < Setting.password_min_length.to_i
640 if !password.nil? && password.size < Setting.password_min_length.to_i
641 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
641 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
642 end
642 end
643 end
643 end
644
644
645 private
645 private
646
646
647 # Removes references that are not handled by associations
647 # Removes references that are not handled by associations
648 # Things that are not deleted are reassociated with the anonymous user
648 # Things that are not deleted are reassociated with the anonymous user
649 def remove_references_before_destroy
649 def remove_references_before_destroy
650 return if self.id.nil?
650 return if self.id.nil?
651
651
652 substitute = User.anonymous
652 substitute = User.anonymous
653 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
653 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
654 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
654 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
655 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
655 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
656 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
656 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
657 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
657 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
658 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
658 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
659 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
659 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
660 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
660 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
661 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
661 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
662 # Remove private queries and keep public ones
662 # Remove private queries and keep public ones
663 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
663 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
664 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
664 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
665 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
665 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
666 Token.delete_all ['user_id = ?', id]
666 Token.delete_all ['user_id = ?', id]
667 Watcher.delete_all ['user_id = ?', id]
667 Watcher.delete_all ['user_id = ?', id]
668 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
668 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
669 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
669 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
670 end
670 end
671
671
672 # Return password digest
672 # Return password digest
673 def self.hash_password(clear_password)
673 def self.hash_password(clear_password)
674 Digest::SHA1.hexdigest(clear_password || "")
674 Digest::SHA1.hexdigest(clear_password || "")
675 end
675 end
676
676
677 # Returns a 128bits random salt as a hex string (32 chars long)
677 # Returns a 128bits random salt as a hex string (32 chars long)
678 def self.generate_salt
678 def self.generate_salt
679 Redmine::Utils.random_hex(16)
679 Redmine::Utils.random_hex(16)
680 end
680 end
681
681
682 end
682 end
683
683
684 class AnonymousUser < User
684 class AnonymousUser < User
685 validate :validate_anonymous_uniqueness, :on => :create
685 validate :validate_anonymous_uniqueness, :on => :create
686
686
687 def validate_anonymous_uniqueness
687 def validate_anonymous_uniqueness
688 # There should be only one AnonymousUser in the database
688 # There should be only one AnonymousUser in the database
689 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
689 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
690 end
690 end
691
691
692 def available_custom_fields
692 def available_custom_fields
693 []
693 []
694 end
694 end
695
695
696 # Overrides a few properties
696 # Overrides a few properties
697 def logged?; false end
697 def logged?; false end
698 def admin; false end
698 def admin; false end
699 def name(*args); I18n.t(:label_user_anonymous) end
699 def name(*args); I18n.t(:label_user_anonymous) end
700 def mail; nil end
700 def mail; nil end
701 def time_zone; nil end
701 def time_zone; nil end
702 def rss_key; nil end
702 def rss_key; nil end
703
703
704 def pref
704 def pref
705 UserPreference.new(:user => self)
705 UserPreference.new(:user => self)
706 end
706 end
707
707
708 # Anonymous user can not be destroyed
708 # Anonymous user can not be destroyed
709 def destroy
709 def destroy
710 false
710 false
711 end
711 end
712 end
712 end
@@ -1,251 +1,251
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 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 scope :with_updated_on, lambda { {
52 scope :with_updated_on, lambda {
53 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version",
53 select("#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version").
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', 'parent_title',
60 safe_attributes 'parent_id', 'parent_title',
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 content_to = content.versions.find_by_version(version_to)
114 content_to = content.versions.find_by_version(version_to)
115 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
115 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
116 return nil unless content_to && content_from
116 return nil unless content_to && content_from
117
117
118 if content_from.version > content_to.version
118 if content_from.version > content_to.version
119 content_to, content_from = content_from, content_to
119 content_to, content_from = content_from, content_to
120 end
120 end
121
121
122 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
122 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
123 end
123 end
124
124
125 def annotate(version=nil)
125 def annotate(version=nil)
126 version = version ? version.to_i : self.content.version
126 version = version ? version.to_i : self.content.version
127 c = content.versions.find_by_version(version)
127 c = content.versions.find_by_version(version)
128 c ? WikiAnnotate.new(c) : nil
128 c ? WikiAnnotate.new(c) : nil
129 end
129 end
130
130
131 def self.pretty_title(str)
131 def self.pretty_title(str)
132 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
132 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
133 end
133 end
134
134
135 def project
135 def project
136 wiki.project
136 wiki.project
137 end
137 end
138
138
139 def text
139 def text
140 content.text if content
140 content.text if content
141 end
141 end
142
142
143 def updated_on
143 def updated_on
144 unless @updated_on
144 unless @updated_on
145 if time = read_attribute(:updated_on)
145 if time = read_attribute(:updated_on)
146 # content updated_on was eager loaded with the page
146 # content updated_on was eager loaded with the page
147 begin
147 begin
148 @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
148 @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
149 rescue
149 rescue
150 end
150 end
151 else
151 else
152 @updated_on = content && content.updated_on
152 @updated_on = content && content.updated_on
153 end
153 end
154 end
154 end
155 @updated_on
155 @updated_on
156 end
156 end
157
157
158 # Returns true if usr is allowed to edit the page, otherwise false
158 # Returns true if usr is allowed to edit the page, otherwise false
159 def editable_by?(usr)
159 def editable_by?(usr)
160 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
160 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
161 end
161 end
162
162
163 def attachments_deletable?(usr=User.current)
163 def attachments_deletable?(usr=User.current)
164 editable_by?(usr) && super(usr)
164 editable_by?(usr) && super(usr)
165 end
165 end
166
166
167 def parent_title
167 def parent_title
168 @parent_title || (self.parent && self.parent.pretty_title)
168 @parent_title || (self.parent && self.parent.pretty_title)
169 end
169 end
170
170
171 def parent_title=(t)
171 def parent_title=(t)
172 @parent_title = t
172 @parent_title = t
173 parent_page = t.blank? ? nil : self.wiki.find_page(t)
173 parent_page = t.blank? ? nil : self.wiki.find_page(t)
174 self.parent = parent_page
174 self.parent = parent_page
175 end
175 end
176
176
177 # Saves the page and its content if text was changed
177 # Saves the page and its content if text was changed
178 def save_with_content
178 def save_with_content
179 ret = nil
179 ret = nil
180 transaction do
180 transaction do
181 if new_record?
181 if new_record?
182 # Rails automatically saves associated content
182 # Rails automatically saves associated content
183 ret = save
183 ret = save
184 else
184 else
185 ret = save && (content.text_changed? ? content.save : true)
185 ret = save && (content.text_changed? ? content.save : true)
186 end
186 end
187 raise ActiveRecord::Rollback unless ret
187 raise ActiveRecord::Rollback unless ret
188 end
188 end
189 ret
189 ret
190 end
190 end
191
191
192 protected
192 protected
193
193
194 def validate_parent_title
194 def validate_parent_title
195 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
195 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
196 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
196 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
197 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
197 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
198 end
198 end
199 end
199 end
200
200
201 class WikiDiff < Redmine::Helpers::Diff
201 class WikiDiff < Redmine::Helpers::Diff
202 attr_reader :content_to, :content_from
202 attr_reader :content_to, :content_from
203
203
204 def initialize(content_to, content_from)
204 def initialize(content_to, content_from)
205 @content_to = content_to
205 @content_to = content_to
206 @content_from = content_from
206 @content_from = content_from
207 super(content_to.text, content_from.text)
207 super(content_to.text, content_from.text)
208 end
208 end
209 end
209 end
210
210
211 class WikiAnnotate
211 class WikiAnnotate
212 attr_reader :lines, :content
212 attr_reader :lines, :content
213
213
214 def initialize(content)
214 def initialize(content)
215 @content = content
215 @content = content
216 current = content
216 current = content
217 current_lines = current.text.split(/\r?\n/)
217 current_lines = current.text.split(/\r?\n/)
218 @lines = current_lines.collect {|t| [nil, nil, t]}
218 @lines = current_lines.collect {|t| [nil, nil, t]}
219 positions = []
219 positions = []
220 current_lines.size.times {|i| positions << i}
220 current_lines.size.times {|i| positions << i}
221 while (current.previous)
221 while (current.previous)
222 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
222 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
223 d.each_slice(3) do |s|
223 d.each_slice(3) do |s|
224 sign, line = s[0], s[1]
224 sign, line = s[0], s[1]
225 if sign == '+' && positions[line] && positions[line] != -1
225 if sign == '+' && positions[line] && positions[line] != -1
226 if @lines[positions[line]][0].nil?
226 if @lines[positions[line]][0].nil?
227 @lines[positions[line]][0] = current.version
227 @lines[positions[line]][0] = current.version
228 @lines[positions[line]][1] = current.author
228 @lines[positions[line]][1] = current.author
229 end
229 end
230 end
230 end
231 end
231 end
232 d.each_slice(3) do |s|
232 d.each_slice(3) do |s|
233 sign, line = s[0], s[1]
233 sign, line = s[0], s[1]
234 if sign == '-'
234 if sign == '-'
235 positions.insert(line, -1)
235 positions.insert(line, -1)
236 else
236 else
237 positions[line] = nil
237 positions[line] = nil
238 end
238 end
239 end
239 end
240 positions.compact!
240 positions.compact!
241 # Stop if every line is annotated
241 # Stop if every line is annotated
242 break unless @lines.detect { |line| line[0].nil? }
242 break unless @lines.detect { |line| line[0].nil? }
243 current = current.previous
243 current = current.previous
244 end
244 end
245 @lines.each { |line|
245 @lines.each { |line|
246 line[0] ||= current.version
246 line[0] ||= current.version
247 # if the last known version is > 1 (eg. history was cleared), we don't know the author
247 # if the last known version is > 1 (eg. history was cleared), we don't know the author
248 line[1] ||= current.author if current.version == 1
248 line[1] ||= current.author if current.version == 1
249 }
249 }
250 end
250 end
251 end
251 end
General Comments 0
You need to be logged in to leave comments. Login now