##// END OF EJS Templates
Rewrites search engine to properly paginate results (#18631)....
Jean-Philippe Lang -
r13357:2fe806a4a49c
parent child
Show More
@@ -1,111 +1,113
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 SearchController < ApplicationController
18 class SearchController < ApplicationController
19 before_filter :find_optional_project
19 before_filter :find_optional_project
20
20
21 def index
21 def index
22 @question = params[:q] || ""
22 @question = params[:q] || ""
23 @question.strip!
23 @question.strip!
24 @all_words = params[:all_words] ? params[:all_words].present? : true
24 @all_words = params[:all_words] ? params[:all_words].present? : true
25 @titles_only = params[:titles_only] ? params[:titles_only].present? : false
25 @titles_only = params[:titles_only] ? params[:titles_only].present? : false
26
26
27 projects_to_search =
27 projects_to_search =
28 case params[:scope]
28 case params[:scope]
29 when 'all'
29 when 'all'
30 nil
30 nil
31 when 'my_projects'
31 when 'my_projects'
32 User.current.memberships.collect(&:project)
32 User.current.memberships.collect(&:project)
33 when 'subprojects'
33 when 'subprojects'
34 @project ? (@project.self_and_descendants.active.to_a) : nil
34 @project ? (@project.self_and_descendants.active.to_a) : nil
35 else
35 else
36 @project
36 @project
37 end
37 end
38
38
39 offset = nil
40 begin; offset = params[:offset].to_time if params[:offset]; rescue; end
41
42 # quick jump to an issue
39 # quick jump to an issue
43 if (m = @question.match(/^#?(\d+)$/)) && (issue = Issue.visible.find_by_id(m[1].to_i))
40 if (m = @question.match(/^#?(\d+)$/)) && (issue = Issue.visible.find_by_id(m[1].to_i))
44 redirect_to issue_path(issue)
41 redirect_to issue_path(issue)
45 return
42 return
46 end
43 end
47
44
48 @object_types = Redmine::Search.available_search_types.dup
45 @object_types = Redmine::Search.available_search_types.dup
49 if projects_to_search.is_a? Project
46 if projects_to_search.is_a? Project
50 # don't search projects
47 # don't search projects
51 @object_types.delete('projects')
48 @object_types.delete('projects')
52 # only show what the user is allowed to view
49 # only show what the user is allowed to view
53 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
50 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
54 end
51 end
55
52
56 @scope = @object_types.select {|t| params[t]}
53 @scope = @object_types.select {|t| params[t]}
57 @scope = @object_types if @scope.empty?
54 @scope = @object_types if @scope.empty?
58
55
59 # extract tokens from the question
56 # extract tokens from the question
60 # eg. hello "bye bye" => ["hello", "bye bye"]
57 # eg. hello "bye bye" => ["hello", "bye bye"]
61 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
58 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
62 # tokens must be at least 2 characters long
59 # tokens must be at least 2 characters long
63 @tokens = @tokens.uniq.select {|w| w.length > 1 }
60 @tokens = @tokens.uniq.select {|w| w.length > 1 }
64
61
65 if !@tokens.empty?
62 if !@tokens.empty?
66 # no more than 5 tokens to search for
63 # no more than 5 tokens to search for
67 @tokens.slice! 5..-1 if @tokens.size > 5
64 @tokens.slice! 5..-1 if @tokens.size > 5
68
65
69 @results = []
70 @results_by_type = Hash.new {|h,k| h[k] = 0}
71
72 limit = 10
66 limit = 10
73 @scope.each do |s|
67
74 r, c = s.singularize.camelcase.constantize.search(@tokens, projects_to_search,
68 @result_count = 0
69 @result_count_by_type = Hash.new {|h,k| h[k] = 0}
70 ranks_and_ids = []
71
72 # get all the results ranks and ids
73 @scope.each do |scope|
74 klass = scope.singularize.camelcase.constantize
75 ranks_and_ids_in_scope = klass.search_result_ranks_and_ids(@tokens, User.current, projects_to_search,
75 :all_words => @all_words,
76 :all_words => @all_words,
76 :titles_only => @titles_only,
77 :titles_only => @titles_only
77 :limit => (limit+1),
78 )
78 :offset => offset,
79 @result_count_by_type[scope] += ranks_and_ids_in_scope.size
79 :before => params[:previous].nil?)
80 @result_count += ranks_and_ids_in_scope.size
80 @results += r
81 ranks_and_ids += ranks_and_ids_in_scope.map {|r| [scope, r]}
81 @results_by_type[s] += c
82 end
82 end
83 @results = @results.sort {|a,b| b.event_datetime <=> a.event_datetime}
83 @result_pages = Paginator.new @result_count, limit, params['page']
84 if params[:previous].nil?
84
85 @pagination_previous_date = @results[0].event_datetime if offset && @results[0]
85 # sort results, higher rank and id first
86 if @results.size > limit
86 ranks_and_ids.sort! {|a,b| b.last <=> a.last }
87 @pagination_next_date = @results[limit-1].event_datetime
87 ranks_and_ids = ranks_and_ids[@result_pages.offset, limit] || []
88 @results = @results[0, limit]
88
89 end
89 # load the results to display
90 else
90 results_by_scope = Hash.new {|h,k| h[k] = []}
91 @pagination_next_date = @results[-1].event_datetime if offset && @results[-1]
91 ranks_and_ids.group_by(&:first).each do |scope, rs|
92 if @results.size > limit
92 klass = scope.singularize.camelcase.constantize
93 @pagination_previous_date = @results[-(limit)].event_datetime
93 results_by_scope[scope] += klass.search_results_from_ids(rs.map(&:last).map(&:last))
94 @results = @results[-(limit), limit]
95 end
96 end
94 end
95
96 @results = ranks_and_ids.map do |scope, r|
97 results_by_scope[scope].detect {|record| record.id == r.last}
98 end.compact
97 else
99 else
98 @question = ""
100 @question = ""
99 end
101 end
100 render :layout => false if request.xhr?
102 render :layout => false if request.xhr?
101 end
103 end
102
104
103 private
105 private
104 def find_optional_project
106 def find_optional_project
105 return true unless params[:id]
107 return true unless params[:id]
106 @project = Project.find(params[:id])
108 @project = Project.find(params[:id])
107 check_project_privacy
109 check_project_privacy
108 rescue ActiveRecord::RecordNotFound
110 rescue ActiveRecord::RecordNotFound
109 render_404
111 render_404
110 end
112 end
111 end
113 end
@@ -1,291 +1,291
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 Changeset < ActiveRecord::Base
18 class Changeset < ActiveRecord::Base
19 belongs_to :repository
19 belongs_to :repository
20 belongs_to :user
20 belongs_to :user
21 has_many :filechanges, :class_name => 'Change', :dependent => :delete_all
21 has_many :filechanges, :class_name => 'Change', :dependent => :delete_all
22 has_and_belongs_to_many :issues
22 has_and_belongs_to_many :issues
23 has_and_belongs_to_many :parents,
23 has_and_belongs_to_many :parents,
24 :class_name => "Changeset",
24 :class_name => "Changeset",
25 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
25 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
26 :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
26 :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
27 has_and_belongs_to_many :children,
27 has_and_belongs_to_many :children,
28 :class_name => "Changeset",
28 :class_name => "Changeset",
29 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
29 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
30 :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
30 :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
31
31
32 acts_as_event :title => Proc.new {|o| o.title},
32 acts_as_event :title => Proc.new {|o| o.title},
33 :description => :long_comments,
33 :description => :long_comments,
34 :datetime => :committed_on,
34 :datetime => :committed_on,
35 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
35 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
36
36
37 acts_as_searchable :columns => 'comments',
37 acts_as_searchable :columns => 'comments',
38 :scope => preload(:repository => :project),
38 :preload => {:repository => :project},
39 :project_key => "#{Repository.table_name}.project_id",
39 :project_key => "#{Repository.table_name}.project_id",
40 :date_column => "#{Changeset.table_name}.committed_on"
40 :date_column => :committed_on
41
41
42 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
42 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
43 :author_key => :user_id,
43 :author_key => :user_id,
44 :scope => preload(:user, {:repository => :project})
44 :scope => preload(:user, {:repository => :project})
45
45
46 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
46 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
47 validates_uniqueness_of :revision, :scope => :repository_id
47 validates_uniqueness_of :revision, :scope => :repository_id
48 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
48 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
49 attr_protected :id
49 attr_protected :id
50
50
51 scope :visible, lambda {|*args|
51 scope :visible, lambda {|*args|
52 joins(:repository => :project).
52 joins(:repository => :project).
53 where(Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args))
53 where(Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args))
54 }
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_update_keywords_array.map {|r| r['keywords']}.flatten.compact
123 fix_keywords = Setting.commit_update_keywords_array.map {|r| r['keywords']}.flatten.compact
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].to_s.downcase, match[3]
130 action, refs = match[2].to_s.downcase, 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 && !issue_linked_to_same_commit?(issue)
135 if issue && !issue_linked_to_same_commit?(issue)
136 referenced_issues << issue
136 referenced_issues << issue
137 # Don't update issues or log time when importing old commits
137 # Don't update issues or log time when importing old commits
138 unless repository.created_on && committed_on && committed_on < repository.created_on
138 unless repository.created_on && committed_on && committed_on < repository.created_on
139 fix_issue(issue, action) if fix_keywords.include?(action)
139 fix_issue(issue, action) if fix_keywords.include?(action)
140 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
140 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
141 end
141 end
142 end
142 end
143 end
143 end
144 end
144 end
145
145
146 referenced_issues.uniq!
146 referenced_issues.uniq!
147 self.issues = referenced_issues unless referenced_issues.empty?
147 self.issues = referenced_issues unless referenced_issues.empty?
148 end
148 end
149
149
150 def short_comments
150 def short_comments
151 @short_comments || split_comments.first
151 @short_comments || split_comments.first
152 end
152 end
153
153
154 def long_comments
154 def long_comments
155 @long_comments || split_comments.last
155 @long_comments || split_comments.last
156 end
156 end
157
157
158 def text_tag(ref_project=nil)
158 def text_tag(ref_project=nil)
159 repo = ""
159 repo = ""
160 if repository && repository.identifier.present?
160 if repository && repository.identifier.present?
161 repo = "#{repository.identifier}|"
161 repo = "#{repository.identifier}|"
162 end
162 end
163 tag = if scmid?
163 tag = if scmid?
164 "commit:#{repo}#{scmid}"
164 "commit:#{repo}#{scmid}"
165 else
165 else
166 "#{repo}r#{revision}"
166 "#{repo}r#{revision}"
167 end
167 end
168 if ref_project && project && ref_project != project
168 if ref_project && project && ref_project != project
169 tag = "#{project.identifier}:#{tag}"
169 tag = "#{project.identifier}:#{tag}"
170 end
170 end
171 tag
171 tag
172 end
172 end
173
173
174 # Returns the title used for the changeset in the activity/search results
174 # Returns the title used for the changeset in the activity/search results
175 def title
175 def title
176 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
176 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
177 comm = short_comments.blank? ? '' : (': ' + short_comments)
177 comm = short_comments.blank? ? '' : (': ' + short_comments)
178 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
178 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
179 end
179 end
180
180
181 # Returns the previous changeset
181 # Returns the previous changeset
182 def previous
182 def previous
183 @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
183 @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
184 end
184 end
185
185
186 # Returns the next changeset
186 # Returns the next changeset
187 def next
187 def next
188 @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
188 @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
189 end
189 end
190
190
191 # Creates a new Change from it's common parameters
191 # Creates a new Change from it's common parameters
192 def create_change(change)
192 def create_change(change)
193 Change.create(:changeset => self,
193 Change.create(:changeset => self,
194 :action => change[:action],
194 :action => change[:action],
195 :path => change[:path],
195 :path => change[:path],
196 :from_path => change[:from_path],
196 :from_path => change[:from_path],
197 :from_revision => change[:from_revision])
197 :from_revision => change[:from_revision])
198 end
198 end
199
199
200 # Finds an issue that can be referenced by the commit message
200 # Finds an issue that can be referenced by the commit message
201 def find_referenced_issue_by_id(id)
201 def find_referenced_issue_by_id(id)
202 return nil if id.blank?
202 return nil if id.blank?
203 issue = Issue.includes(:project).where(:id => id.to_i).first
203 issue = Issue.includes(:project).where(:id => id.to_i).first
204 if Setting.commit_cross_project_ref?
204 if Setting.commit_cross_project_ref?
205 # all issues can be referenced/fixed
205 # all issues can be referenced/fixed
206 elsif issue
206 elsif issue
207 # issue that belong to the repository project, a subproject or a parent project only
207 # issue that belong to the repository project, a subproject or a parent project only
208 unless issue.project &&
208 unless issue.project &&
209 (project == issue.project || project.is_ancestor_of?(issue.project) ||
209 (project == issue.project || project.is_ancestor_of?(issue.project) ||
210 project.is_descendant_of?(issue.project))
210 project.is_descendant_of?(issue.project))
211 issue = nil
211 issue = nil
212 end
212 end
213 end
213 end
214 issue
214 issue
215 end
215 end
216
216
217 private
217 private
218
218
219 # Returns true if the issue is already linked to the same commit
219 # Returns true if the issue is already linked to the same commit
220 # from a different repository
220 # from a different repository
221 def issue_linked_to_same_commit?(issue)
221 def issue_linked_to_same_commit?(issue)
222 repository.same_commits_in_scope(issue.changesets, self).any?
222 repository.same_commits_in_scope(issue.changesets, self).any?
223 end
223 end
224
224
225 # Updates the +issue+ according to +action+
225 # Updates the +issue+ according to +action+
226 def fix_issue(issue, action)
226 def fix_issue(issue, action)
227 # the issue may have been updated by the closure of another one (eg. duplicate)
227 # the issue may have been updated by the closure of another one (eg. duplicate)
228 issue.reload
228 issue.reload
229 # don't change the status is the issue is closed
229 # don't change the status is the issue is closed
230 return if issue.closed?
230 return if issue.closed?
231
231
232 journal = issue.init_journal(user || User.anonymous,
232 journal = issue.init_journal(user || User.anonymous,
233 ll(Setting.default_language,
233 ll(Setting.default_language,
234 :text_status_changed_by_changeset,
234 :text_status_changed_by_changeset,
235 text_tag(issue.project)))
235 text_tag(issue.project)))
236 rule = Setting.commit_update_keywords_array.detect do |rule|
236 rule = Setting.commit_update_keywords_array.detect do |rule|
237 rule['keywords'].include?(action) &&
237 rule['keywords'].include?(action) &&
238 (rule['if_tracker_id'].blank? || rule['if_tracker_id'] == issue.tracker_id.to_s)
238 (rule['if_tracker_id'].blank? || rule['if_tracker_id'] == issue.tracker_id.to_s)
239 end
239 end
240 if rule
240 if rule
241 issue.assign_attributes rule.slice(*Issue.attribute_names)
241 issue.assign_attributes rule.slice(*Issue.attribute_names)
242 end
242 end
243 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
243 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
244 { :changeset => self, :issue => issue, :action => action })
244 { :changeset => self, :issue => issue, :action => action })
245 unless issue.save
245 unless issue.save
246 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
246 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
247 end
247 end
248 issue
248 issue
249 end
249 end
250
250
251 def log_time(issue, hours)
251 def log_time(issue, hours)
252 time_entry = TimeEntry.new(
252 time_entry = TimeEntry.new(
253 :user => user,
253 :user => user,
254 :hours => hours,
254 :hours => hours,
255 :issue => issue,
255 :issue => issue,
256 :spent_on => commit_date,
256 :spent_on => commit_date,
257 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
257 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
258 :locale => Setting.default_language)
258 :locale => Setting.default_language)
259 )
259 )
260 time_entry.activity = log_time_activity unless log_time_activity.nil?
260 time_entry.activity = log_time_activity unless log_time_activity.nil?
261
261
262 unless time_entry.save
262 unless time_entry.save
263 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
263 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
264 end
264 end
265 time_entry
265 time_entry
266 end
266 end
267
267
268 def log_time_activity
268 def log_time_activity
269 if Setting.commit_logtime_activity_id.to_i > 0
269 if Setting.commit_logtime_activity_id.to_i > 0
270 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
270 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
271 end
271 end
272 end
272 end
273
273
274 def split_comments
274 def split_comments
275 comments =~ /\A(.+?)\r?\n(.*)$/m
275 comments =~ /\A(.+?)\r?\n(.*)$/m
276 @short_comments = $1 || comments
276 @short_comments = $1 || comments
277 @long_comments = $2.to_s.strip
277 @long_comments = $2.to_s.strip
278 return @short_comments, @long_comments
278 return @short_comments, @long_comments
279 end
279 end
280
280
281 public
281 public
282
282
283 # Strips and reencodes a commit log before insertion into the database
283 # Strips and reencodes a commit log before insertion into the database
284 def self.normalize_comments(str, encoding)
284 def self.normalize_comments(str, encoding)
285 Changeset.to_utf8(str.to_s.strip, encoding)
285 Changeset.to_utf8(str.to_s.strip, encoding)
286 end
286 end
287
287
288 def self.to_utf8(str, encoding)
288 def self.to_utf8(str, encoding)
289 Redmine::CodesetUtil.to_utf8(str, encoding)
289 Redmine::CodesetUtil.to_utf8(str, encoding)
290 end
290 end
291 end
291 end
@@ -1,70 +1,70
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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"
21 belongs_to :category, :class_name => "DocumentCategory"
22 acts_as_attachable :delete_permission => :delete_documents
22 acts_as_attachable :delete_permission => :delete_documents
23
23
24 acts_as_searchable :columns => ['title', "#{table_name}.description"],
24 acts_as_searchable :columns => ['title', "#{table_name}.description"],
25 :scope => preload(:project)
25 :preload => :project
26 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
26 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
27 :author => Proc.new {|o| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
27 :author => Proc.new {|o| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
28 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
28 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
29 acts_as_activity_provider :scope => preload(:project)
29 acts_as_activity_provider :scope => preload(:project)
30
30
31 validates_presence_of :project, :title, :category
31 validates_presence_of :project, :title, :category
32 validates_length_of :title, :maximum => 60
32 validates_length_of :title, :maximum => 60
33 attr_protected :id
33 attr_protected :id
34
34
35 after_create :send_notification
35 after_create :send_notification
36
36
37 scope :visible, lambda {|*args|
37 scope :visible, lambda {|*args|
38 joins(:project).
38 joins(:project).
39 where(Project.allowed_to_condition(args.shift || User.current, :view_documents, *args))
39 where(Project.allowed_to_condition(args.shift || User.current, :view_documents, *args))
40 }
40 }
41
41
42 safe_attributes 'category_id', 'title', 'description'
42 safe_attributes 'category_id', 'title', 'description'
43
43
44 def visible?(user=User.current)
44 def visible?(user=User.current)
45 !user.nil? && user.allowed_to?(:view_documents, project)
45 !user.nil? && user.allowed_to?(:view_documents, project)
46 end
46 end
47
47
48 def initialize(attributes=nil, *args)
48 def initialize(attributes=nil, *args)
49 super
49 super
50 if new_record?
50 if new_record?
51 self.category ||= DocumentCategory.default
51 self.category ||= DocumentCategory.default
52 end
52 end
53 end
53 end
54
54
55 def updated_on
55 def updated_on
56 unless @updated_on
56 unless @updated_on
57 a = attachments.last
57 a = attachments.last
58 @updated_on = (a && a.created_on) || created_on
58 @updated_on = (a && a.created_on) || created_on
59 end
59 end
60 @updated_on
60 @updated_on
61 end
61 end
62
62
63 private
63 private
64
64
65 def send_notification
65 def send_notification
66 if Setting.notified_events.include?('document_added')
66 if Setting.notified_events.include?('document_added')
67 Mailer.document_added(self).deliver
67 Mailer.document_added(self).deliver
68 end
68 end
69 end
69 end
70 end
70 end
@@ -1,1606 +1,1605
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 include Redmine::I18n
21 include Redmine::I18n
22
22
23 belongs_to :project
23 belongs_to :project
24 belongs_to :tracker
24 belongs_to :tracker
25 belongs_to :status, :class_name => 'IssueStatus'
25 belongs_to :status, :class_name => 'IssueStatus'
26 belongs_to :author, :class_name => 'User'
26 belongs_to :author, :class_name => 'User'
27 belongs_to :assigned_to, :class_name => 'Principal'
27 belongs_to :assigned_to, :class_name => 'Principal'
28 belongs_to :fixed_version, :class_name => 'Version'
28 belongs_to :fixed_version, :class_name => 'Version'
29 belongs_to :priority, :class_name => 'IssuePriority'
29 belongs_to :priority, :class_name => 'IssuePriority'
30 belongs_to :category, :class_name => 'IssueCategory'
30 belongs_to :category, :class_name => 'IssueCategory'
31
31
32 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :journals, :as => :journalized, :dependent => :destroy
33 has_many :visible_journals,
33 has_many :visible_journals,
34 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
34 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
35 :class_name => 'Journal',
35 :class_name => 'Journal',
36 :as => :journalized
36 :as => :journalized
37
37
38 has_many :time_entries, :dependent => :destroy
38 has_many :time_entries, :dependent => :destroy
39 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
39 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
40
40
41 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
41 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
42 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
42 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
43
43
44 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
44 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
45 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
45 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
46 acts_as_customizable
46 acts_as_customizable
47 acts_as_watchable
47 acts_as_watchable
48 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
48 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
49 # sort by id so that limited eager loading doesn't break with postgresql
49 :preload => [:project, :status, :tracker],
50 :order_column => "#{table_name}.id",
51 :scope => lambda { joins(:project).
50 :scope => lambda { joins(:project).
52 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
51 joins("LEFT OUTER JOIN #{Journal.table_name} ON #{Journal.table_name}.journalized_type='Issue'" +
53 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
52 " AND #{Journal.table_name}.journalized_id = #{Issue.table_name}.id" +
54 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
53 " AND (#{Journal.table_name}.private_notes = #{connection.quoted_false}" +
55 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
54 " OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))") }
56
55
57 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
56 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
58 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
57 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
59 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
58 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
60
59
61 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
60 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
62 :author_key => :author_id
61 :author_key => :author_id
63
62
64 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
63 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
65
64
66 attr_reader :current_journal
65 attr_reader :current_journal
67 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
66 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
68
67
69 validates_presence_of :subject, :project, :tracker
68 validates_presence_of :subject, :project, :tracker
70 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
69 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
71 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
70 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
72 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
71 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
73
72
74 validates_length_of :subject, :maximum => 255
73 validates_length_of :subject, :maximum => 255
75 validates_inclusion_of :done_ratio, :in => 0..100
74 validates_inclusion_of :done_ratio, :in => 0..100
76 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
75 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
77 validates :start_date, :date => true
76 validates :start_date, :date => true
78 validates :due_date, :date => true
77 validates :due_date, :date => true
79 validate :validate_issue, :validate_required_fields
78 validate :validate_issue, :validate_required_fields
80 attr_protected :id
79 attr_protected :id
81
80
82 scope :visible, lambda {|*args|
81 scope :visible, lambda {|*args|
83 includes(:project).
82 includes(:project).
84 references(:project).
83 references(:project).
85 where(Issue.visible_condition(args.shift || User.current, *args))
84 where(Issue.visible_condition(args.shift || User.current, *args))
86 }
85 }
87
86
88 scope :open, lambda {|*args|
87 scope :open, lambda {|*args|
89 is_closed = args.size > 0 ? !args.first : false
88 is_closed = args.size > 0 ? !args.first : false
90 joins(:status).
89 joins(:status).
91 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
90 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
92 }
91 }
93
92
94 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
93 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
95 scope :on_active_project, lambda {
94 scope :on_active_project, lambda {
96 joins(:project).
95 joins(:project).
97 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
96 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
98 }
97 }
99 scope :fixed_version, lambda {|versions|
98 scope :fixed_version, lambda {|versions|
100 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
99 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
101 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
100 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
102 }
101 }
103
102
104 before_create :default_assign
103 before_create :default_assign
105 before_save :close_duplicates, :update_done_ratio_from_issue_status,
104 before_save :close_duplicates, :update_done_ratio_from_issue_status,
106 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
105 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
107 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
106 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
108 after_save :reschedule_following_issues, :update_nested_set_attributes,
107 after_save :reschedule_following_issues, :update_nested_set_attributes,
109 :update_parent_attributes, :create_journal
108 :update_parent_attributes, :create_journal
110 # Should be after_create but would be called before previous after_save callbacks
109 # Should be after_create but would be called before previous after_save callbacks
111 after_save :after_create_from_copy
110 after_save :after_create_from_copy
112 after_destroy :update_parent_attributes
111 after_destroy :update_parent_attributes
113 after_create :send_notification
112 after_create :send_notification
114 # Keep it at the end of after_save callbacks
113 # Keep it at the end of after_save callbacks
115 after_save :clear_assigned_to_was
114 after_save :clear_assigned_to_was
116
115
117 # Returns a SQL conditions string used to find all issues visible by the specified user
116 # Returns a SQL conditions string used to find all issues visible by the specified user
118 def self.visible_condition(user, options={})
117 def self.visible_condition(user, options={})
119 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
118 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
120 if user.id && user.logged?
119 if user.id && user.logged?
121 case role.issues_visibility
120 case role.issues_visibility
122 when 'all'
121 when 'all'
123 nil
122 nil
124 when 'default'
123 when 'default'
125 user_ids = [user.id] + user.groups.map(&:id).compact
124 user_ids = [user.id] + user.groups.map(&:id).compact
126 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
125 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
127 when 'own'
126 when 'own'
128 user_ids = [user.id] + user.groups.map(&:id).compact
127 user_ids = [user.id] + user.groups.map(&:id).compact
129 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
128 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
130 else
129 else
131 '1=0'
130 '1=0'
132 end
131 end
133 else
132 else
134 "(#{table_name}.is_private = #{connection.quoted_false})"
133 "(#{table_name}.is_private = #{connection.quoted_false})"
135 end
134 end
136 end
135 end
137 end
136 end
138
137
139 # Returns true if usr or current user is allowed to view the issue
138 # Returns true if usr or current user is allowed to view the issue
140 def visible?(usr=nil)
139 def visible?(usr=nil)
141 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
140 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
142 if user.logged?
141 if user.logged?
143 case role.issues_visibility
142 case role.issues_visibility
144 when 'all'
143 when 'all'
145 true
144 true
146 when 'default'
145 when 'default'
147 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
146 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
148 when 'own'
147 when 'own'
149 self.author == user || user.is_or_belongs_to?(assigned_to)
148 self.author == user || user.is_or_belongs_to?(assigned_to)
150 else
149 else
151 false
150 false
152 end
151 end
153 else
152 else
154 !self.is_private?
153 !self.is_private?
155 end
154 end
156 end
155 end
157 end
156 end
158
157
159 # Returns true if user or current user is allowed to edit or add a note to the issue
158 # Returns true if user or current user is allowed to edit or add a note to the issue
160 def editable?(user=User.current)
159 def editable?(user=User.current)
161 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
160 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
162 end
161 end
163
162
164 def initialize(attributes=nil, *args)
163 def initialize(attributes=nil, *args)
165 super
164 super
166 if new_record?
165 if new_record?
167 # set default values for new records only
166 # set default values for new records only
168 self.priority ||= IssuePriority.default
167 self.priority ||= IssuePriority.default
169 self.watcher_user_ids = []
168 self.watcher_user_ids = []
170 end
169 end
171 end
170 end
172
171
173 def create_or_update
172 def create_or_update
174 super
173 super
175 ensure
174 ensure
176 @status_was = nil
175 @status_was = nil
177 end
176 end
178 private :create_or_update
177 private :create_or_update
179
178
180 # AR#Persistence#destroy would raise and RecordNotFound exception
179 # AR#Persistence#destroy would raise and RecordNotFound exception
181 # if the issue was already deleted or updated (non matching lock_version).
180 # if the issue was already deleted or updated (non matching lock_version).
182 # This is a problem when bulk deleting issues or deleting a project
181 # This is a problem when bulk deleting issues or deleting a project
183 # (because an issue may already be deleted if its parent was deleted
182 # (because an issue may already be deleted if its parent was deleted
184 # first).
183 # first).
185 # The issue is reloaded by the nested_set before being deleted so
184 # The issue is reloaded by the nested_set before being deleted so
186 # the lock_version condition should not be an issue but we handle it.
185 # the lock_version condition should not be an issue but we handle it.
187 def destroy
186 def destroy
188 super
187 super
189 rescue ActiveRecord::RecordNotFound
188 rescue ActiveRecord::RecordNotFound
190 # Stale or already deleted
189 # Stale or already deleted
191 begin
190 begin
192 reload
191 reload
193 rescue ActiveRecord::RecordNotFound
192 rescue ActiveRecord::RecordNotFound
194 # The issue was actually already deleted
193 # The issue was actually already deleted
195 @destroyed = true
194 @destroyed = true
196 return freeze
195 return freeze
197 end
196 end
198 # The issue was stale, retry to destroy
197 # The issue was stale, retry to destroy
199 super
198 super
200 end
199 end
201
200
202 alias :base_reload :reload
201 alias :base_reload :reload
203 def reload(*args)
202 def reload(*args)
204 @workflow_rule_by_attribute = nil
203 @workflow_rule_by_attribute = nil
205 @assignable_versions = nil
204 @assignable_versions = nil
206 @relations = nil
205 @relations = nil
207 @spent_hours = nil
206 @spent_hours = nil
208 base_reload(*args)
207 base_reload(*args)
209 end
208 end
210
209
211 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
210 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
212 def available_custom_fields
211 def available_custom_fields
213 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
212 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
214 end
213 end
215
214
216 def visible_custom_field_values(user=nil)
215 def visible_custom_field_values(user=nil)
217 user_real = user || User.current
216 user_real = user || User.current
218 custom_field_values.select do |value|
217 custom_field_values.select do |value|
219 value.custom_field.visible_by?(project, user_real)
218 value.custom_field.visible_by?(project, user_real)
220 end
219 end
221 end
220 end
222
221
223 # Copies attributes from another issue, arg can be an id or an Issue
222 # Copies attributes from another issue, arg can be an id or an Issue
224 def copy_from(arg, options={})
223 def copy_from(arg, options={})
225 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
224 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
226 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
225 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
227 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
226 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
228 self.status = issue.status
227 self.status = issue.status
229 self.author = User.current
228 self.author = User.current
230 unless options[:attachments] == false
229 unless options[:attachments] == false
231 self.attachments = issue.attachments.map do |attachement|
230 self.attachments = issue.attachments.map do |attachement|
232 attachement.copy(:container => self)
231 attachement.copy(:container => self)
233 end
232 end
234 end
233 end
235 @copied_from = issue
234 @copied_from = issue
236 @copy_options = options
235 @copy_options = options
237 self
236 self
238 end
237 end
239
238
240 # Returns an unsaved copy of the issue
239 # Returns an unsaved copy of the issue
241 def copy(attributes=nil, copy_options={})
240 def copy(attributes=nil, copy_options={})
242 copy = self.class.new.copy_from(self, copy_options)
241 copy = self.class.new.copy_from(self, copy_options)
243 copy.attributes = attributes if attributes
242 copy.attributes = attributes if attributes
244 copy
243 copy
245 end
244 end
246
245
247 # Returns true if the issue is a copy
246 # Returns true if the issue is a copy
248 def copy?
247 def copy?
249 @copied_from.present?
248 @copied_from.present?
250 end
249 end
251
250
252 def status_id=(status_id)
251 def status_id=(status_id)
253 if status_id.to_s != self.status_id.to_s
252 if status_id.to_s != self.status_id.to_s
254 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
253 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
255 end
254 end
256 self.status_id
255 self.status_id
257 end
256 end
258
257
259 # Sets the status.
258 # Sets the status.
260 def status=(status)
259 def status=(status)
261 if status != self.status
260 if status != self.status
262 @workflow_rule_by_attribute = nil
261 @workflow_rule_by_attribute = nil
263 end
262 end
264 association(:status).writer(status)
263 association(:status).writer(status)
265 end
264 end
266
265
267 def priority_id=(pid)
266 def priority_id=(pid)
268 self.priority = nil
267 self.priority = nil
269 write_attribute(:priority_id, pid)
268 write_attribute(:priority_id, pid)
270 end
269 end
271
270
272 def category_id=(cid)
271 def category_id=(cid)
273 self.category = nil
272 self.category = nil
274 write_attribute(:category_id, cid)
273 write_attribute(:category_id, cid)
275 end
274 end
276
275
277 def fixed_version_id=(vid)
276 def fixed_version_id=(vid)
278 self.fixed_version = nil
277 self.fixed_version = nil
279 write_attribute(:fixed_version_id, vid)
278 write_attribute(:fixed_version_id, vid)
280 end
279 end
281
280
282 def tracker_id=(tracker_id)
281 def tracker_id=(tracker_id)
283 if tracker_id.to_s != self.tracker_id.to_s
282 if tracker_id.to_s != self.tracker_id.to_s
284 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
283 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
285 end
284 end
286 self.tracker_id
285 self.tracker_id
287 end
286 end
288
287
289 # Sets the tracker.
288 # Sets the tracker.
290 # This will set the status to the default status of the new tracker if:
289 # This will set the status to the default status of the new tracker if:
291 # * the status was the default for the previous tracker
290 # * the status was the default for the previous tracker
292 # * or if the status was not part of the new tracker statuses
291 # * or if the status was not part of the new tracker statuses
293 # * or the status was nil
292 # * or the status was nil
294 def tracker=(tracker)
293 def tracker=(tracker)
295 if tracker != self.tracker
294 if tracker != self.tracker
296 if status == default_status
295 if status == default_status
297 self.status = nil
296 self.status = nil
298 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
297 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
299 self.status = nil
298 self.status = nil
300 end
299 end
301 @custom_field_values = nil
300 @custom_field_values = nil
302 @workflow_rule_by_attribute = nil
301 @workflow_rule_by_attribute = nil
303 end
302 end
304 association(:tracker).writer(tracker)
303 association(:tracker).writer(tracker)
305 self.status ||= default_status
304 self.status ||= default_status
306 self.tracker
305 self.tracker
307 end
306 end
308
307
309 def project_id=(project_id)
308 def project_id=(project_id)
310 if project_id.to_s != self.project_id.to_s
309 if project_id.to_s != self.project_id.to_s
311 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
310 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
312 end
311 end
313 self.project_id
312 self.project_id
314 end
313 end
315
314
316 # Sets the project.
315 # Sets the project.
317 # Unless keep_tracker argument is set to true, this will change the tracker
316 # Unless keep_tracker argument is set to true, this will change the tracker
318 # to the first tracker of the new project if the previous tracker is not part
317 # to the first tracker of the new project if the previous tracker is not part
319 # of the new project trackers.
318 # of the new project trackers.
320 # This will clear the fixed_version is it's no longer valid for the new project.
319 # This will clear the fixed_version is it's no longer valid for the new project.
321 # This will clear the parent issue if it's no longer valid for the new project.
320 # This will clear the parent issue if it's no longer valid for the new project.
322 # This will set the category to the category with the same name in the new
321 # This will set the category to the category with the same name in the new
323 # project if it exists, or clear it if it doesn't.
322 # project if it exists, or clear it if it doesn't.
324 def project=(project, keep_tracker=false)
323 def project=(project, keep_tracker=false)
325 project_was = self.project
324 project_was = self.project
326 association(:project).writer(project)
325 association(:project).writer(project)
327 if project_was && project && project_was != project
326 if project_was && project && project_was != project
328 @assignable_versions = nil
327 @assignable_versions = nil
329
328
330 unless keep_tracker || project.trackers.include?(tracker)
329 unless keep_tracker || project.trackers.include?(tracker)
331 self.tracker = project.trackers.first
330 self.tracker = project.trackers.first
332 end
331 end
333 # Reassign to the category with same name if any
332 # Reassign to the category with same name if any
334 if category
333 if category
335 self.category = project.issue_categories.find_by_name(category.name)
334 self.category = project.issue_categories.find_by_name(category.name)
336 end
335 end
337 # Keep the fixed_version if it's still valid in the new_project
336 # Keep the fixed_version if it's still valid in the new_project
338 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
337 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
339 self.fixed_version = nil
338 self.fixed_version = nil
340 end
339 end
341 # Clear the parent task if it's no longer valid
340 # Clear the parent task if it's no longer valid
342 unless valid_parent_project?
341 unless valid_parent_project?
343 self.parent_issue_id = nil
342 self.parent_issue_id = nil
344 end
343 end
345 @custom_field_values = nil
344 @custom_field_values = nil
346 @workflow_rule_by_attribute = nil
345 @workflow_rule_by_attribute = nil
347 end
346 end
348 self.project
347 self.project
349 end
348 end
350
349
351 def description=(arg)
350 def description=(arg)
352 if arg.is_a?(String)
351 if arg.is_a?(String)
353 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
352 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
354 end
353 end
355 write_attribute(:description, arg)
354 write_attribute(:description, arg)
356 end
355 end
357
356
358 # Overrides assign_attributes so that project and tracker get assigned first
357 # Overrides assign_attributes so that project and tracker get assigned first
359 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
358 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
360 return if new_attributes.nil?
359 return if new_attributes.nil?
361 attrs = new_attributes.dup
360 attrs = new_attributes.dup
362 attrs.stringify_keys!
361 attrs.stringify_keys!
363
362
364 %w(project project_id tracker tracker_id).each do |attr|
363 %w(project project_id tracker tracker_id).each do |attr|
365 if attrs.has_key?(attr)
364 if attrs.has_key?(attr)
366 send "#{attr}=", attrs.delete(attr)
365 send "#{attr}=", attrs.delete(attr)
367 end
366 end
368 end
367 end
369 send :assign_attributes_without_project_and_tracker_first, attrs, *args
368 send :assign_attributes_without_project_and_tracker_first, attrs, *args
370 end
369 end
371 # Do not redefine alias chain on reload (see #4838)
370 # Do not redefine alias chain on reload (see #4838)
372 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
371 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
373
372
374 def attributes=(new_attributes)
373 def attributes=(new_attributes)
375 assign_attributes new_attributes
374 assign_attributes new_attributes
376 end
375 end
377
376
378 def estimated_hours=(h)
377 def estimated_hours=(h)
379 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
378 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
380 end
379 end
381
380
382 safe_attributes 'project_id',
381 safe_attributes 'project_id',
383 :if => lambda {|issue, user|
382 :if => lambda {|issue, user|
384 if issue.new_record?
383 if issue.new_record?
385 issue.copy?
384 issue.copy?
386 elsif user.allowed_to?(:move_issues, issue.project)
385 elsif user.allowed_to?(:move_issues, issue.project)
387 Issue.allowed_target_projects_on_move.count > 1
386 Issue.allowed_target_projects_on_move.count > 1
388 end
387 end
389 }
388 }
390
389
391 safe_attributes 'tracker_id',
390 safe_attributes 'tracker_id',
392 'status_id',
391 'status_id',
393 'category_id',
392 'category_id',
394 'assigned_to_id',
393 'assigned_to_id',
395 'priority_id',
394 'priority_id',
396 'fixed_version_id',
395 'fixed_version_id',
397 'subject',
396 'subject',
398 'description',
397 'description',
399 'start_date',
398 'start_date',
400 'due_date',
399 'due_date',
401 'done_ratio',
400 'done_ratio',
402 'estimated_hours',
401 'estimated_hours',
403 'custom_field_values',
402 'custom_field_values',
404 'custom_fields',
403 'custom_fields',
405 'lock_version',
404 'lock_version',
406 'notes',
405 'notes',
407 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
406 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
408
407
409 safe_attributes 'status_id',
408 safe_attributes 'status_id',
410 'assigned_to_id',
409 'assigned_to_id',
411 'fixed_version_id',
410 'fixed_version_id',
412 'done_ratio',
411 'done_ratio',
413 'lock_version',
412 'lock_version',
414 'notes',
413 'notes',
415 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
414 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
416
415
417 safe_attributes 'notes',
416 safe_attributes 'notes',
418 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
417 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
419
418
420 safe_attributes 'private_notes',
419 safe_attributes 'private_notes',
421 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
420 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
422
421
423 safe_attributes 'watcher_user_ids',
422 safe_attributes 'watcher_user_ids',
424 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
423 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
425
424
426 safe_attributes 'is_private',
425 safe_attributes 'is_private',
427 :if => lambda {|issue, user|
426 :if => lambda {|issue, user|
428 user.allowed_to?(:set_issues_private, issue.project) ||
427 user.allowed_to?(:set_issues_private, issue.project) ||
429 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
428 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
430 }
429 }
431
430
432 safe_attributes 'parent_issue_id',
431 safe_attributes 'parent_issue_id',
433 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
432 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
434 user.allowed_to?(:manage_subtasks, issue.project)}
433 user.allowed_to?(:manage_subtasks, issue.project)}
435
434
436 def safe_attribute_names(user=nil)
435 def safe_attribute_names(user=nil)
437 names = super
436 names = super
438 names -= disabled_core_fields
437 names -= disabled_core_fields
439 names -= read_only_attribute_names(user)
438 names -= read_only_attribute_names(user)
440 names
439 names
441 end
440 end
442
441
443 # Safely sets attributes
442 # Safely sets attributes
444 # Should be called from controllers instead of #attributes=
443 # Should be called from controllers instead of #attributes=
445 # attr_accessible is too rough because we still want things like
444 # attr_accessible is too rough because we still want things like
446 # Issue.new(:project => foo) to work
445 # Issue.new(:project => foo) to work
447 def safe_attributes=(attrs, user=User.current)
446 def safe_attributes=(attrs, user=User.current)
448 return unless attrs.is_a?(Hash)
447 return unless attrs.is_a?(Hash)
449
448
450 attrs = attrs.deep_dup
449 attrs = attrs.deep_dup
451
450
452 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
451 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
453 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
452 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
454 if allowed_target_projects(user).where(:id => p.to_i).exists?
453 if allowed_target_projects(user).where(:id => p.to_i).exists?
455 self.project_id = p
454 self.project_id = p
456 end
455 end
457 end
456 end
458
457
459 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
458 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
460 self.tracker_id = t
459 self.tracker_id = t
461 end
460 end
462
461
463 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
462 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
464 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
463 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
465 self.status_id = s
464 self.status_id = s
466 end
465 end
467 end
466 end
468
467
469 attrs = delete_unsafe_attributes(attrs, user)
468 attrs = delete_unsafe_attributes(attrs, user)
470 return if attrs.empty?
469 return if attrs.empty?
471
470
472 unless leaf?
471 unless leaf?
473 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
472 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
474 end
473 end
475
474
476 if attrs['parent_issue_id'].present?
475 if attrs['parent_issue_id'].present?
477 s = attrs['parent_issue_id'].to_s
476 s = attrs['parent_issue_id'].to_s
478 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
477 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
479 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
478 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
480 end
479 end
481 end
480 end
482
481
483 if attrs['custom_field_values'].present?
482 if attrs['custom_field_values'].present?
484 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
483 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
485 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
484 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
486 end
485 end
487
486
488 if attrs['custom_fields'].present?
487 if attrs['custom_fields'].present?
489 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
488 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
490 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
489 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
491 end
490 end
492
491
493 # mass-assignment security bypass
492 # mass-assignment security bypass
494 assign_attributes attrs, :without_protection => true
493 assign_attributes attrs, :without_protection => true
495 end
494 end
496
495
497 def disabled_core_fields
496 def disabled_core_fields
498 tracker ? tracker.disabled_core_fields : []
497 tracker ? tracker.disabled_core_fields : []
499 end
498 end
500
499
501 # Returns the custom_field_values that can be edited by the given user
500 # Returns the custom_field_values that can be edited by the given user
502 def editable_custom_field_values(user=nil)
501 def editable_custom_field_values(user=nil)
503 visible_custom_field_values(user).reject do |value|
502 visible_custom_field_values(user).reject do |value|
504 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
503 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
505 end
504 end
506 end
505 end
507
506
508 # Returns the custom fields that can be edited by the given user
507 # Returns the custom fields that can be edited by the given user
509 def editable_custom_fields(user=nil)
508 def editable_custom_fields(user=nil)
510 editable_custom_field_values(user).map(&:custom_field).uniq
509 editable_custom_field_values(user).map(&:custom_field).uniq
511 end
510 end
512
511
513 # Returns the names of attributes that are read-only for user or the current user
512 # Returns the names of attributes that are read-only for user or the current user
514 # For users with multiple roles, the read-only fields are the intersection of
513 # For users with multiple roles, the read-only fields are the intersection of
515 # read-only fields of each role
514 # read-only fields of each role
516 # The result is an array of strings where sustom fields are represented with their ids
515 # The result is an array of strings where sustom fields are represented with their ids
517 #
516 #
518 # Examples:
517 # Examples:
519 # issue.read_only_attribute_names # => ['due_date', '2']
518 # issue.read_only_attribute_names # => ['due_date', '2']
520 # issue.read_only_attribute_names(user) # => []
519 # issue.read_only_attribute_names(user) # => []
521 def read_only_attribute_names(user=nil)
520 def read_only_attribute_names(user=nil)
522 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
521 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
523 end
522 end
524
523
525 # Returns the names of required attributes for user or the current user
524 # Returns the names of required attributes for user or the current user
526 # For users with multiple roles, the required fields are the intersection of
525 # For users with multiple roles, the required fields are the intersection of
527 # required fields of each role
526 # required fields of each role
528 # The result is an array of strings where sustom fields are represented with their ids
527 # The result is an array of strings where sustom fields are represented with their ids
529 #
528 #
530 # Examples:
529 # Examples:
531 # issue.required_attribute_names # => ['due_date', '2']
530 # issue.required_attribute_names # => ['due_date', '2']
532 # issue.required_attribute_names(user) # => []
531 # issue.required_attribute_names(user) # => []
533 def required_attribute_names(user=nil)
532 def required_attribute_names(user=nil)
534 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
533 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
535 end
534 end
536
535
537 # Returns true if the attribute is required for user
536 # Returns true if the attribute is required for user
538 def required_attribute?(name, user=nil)
537 def required_attribute?(name, user=nil)
539 required_attribute_names(user).include?(name.to_s)
538 required_attribute_names(user).include?(name.to_s)
540 end
539 end
541
540
542 # Returns a hash of the workflow rule by attribute for the given user
541 # Returns a hash of the workflow rule by attribute for the given user
543 #
542 #
544 # Examples:
543 # Examples:
545 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
544 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
546 def workflow_rule_by_attribute(user=nil)
545 def workflow_rule_by_attribute(user=nil)
547 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
546 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
548
547
549 user_real = user || User.current
548 user_real = user || User.current
550 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
549 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
551 return {} if roles.empty?
550 return {} if roles.empty?
552
551
553 result = {}
552 result = {}
554 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
553 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
555 if workflow_permissions.any?
554 if workflow_permissions.any?
556 workflow_rules = workflow_permissions.inject({}) do |h, wp|
555 workflow_rules = workflow_permissions.inject({}) do |h, wp|
557 h[wp.field_name] ||= []
556 h[wp.field_name] ||= []
558 h[wp.field_name] << wp.rule
557 h[wp.field_name] << wp.rule
559 h
558 h
560 end
559 end
561 workflow_rules.each do |attr, rules|
560 workflow_rules.each do |attr, rules|
562 next if rules.size < roles.size
561 next if rules.size < roles.size
563 uniq_rules = rules.uniq
562 uniq_rules = rules.uniq
564 if uniq_rules.size == 1
563 if uniq_rules.size == 1
565 result[attr] = uniq_rules.first
564 result[attr] = uniq_rules.first
566 else
565 else
567 result[attr] = 'required'
566 result[attr] = 'required'
568 end
567 end
569 end
568 end
570 end
569 end
571 @workflow_rule_by_attribute = result if user.nil?
570 @workflow_rule_by_attribute = result if user.nil?
572 result
571 result
573 end
572 end
574 private :workflow_rule_by_attribute
573 private :workflow_rule_by_attribute
575
574
576 def done_ratio
575 def done_ratio
577 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
576 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
578 status.default_done_ratio
577 status.default_done_ratio
579 else
578 else
580 read_attribute(:done_ratio)
579 read_attribute(:done_ratio)
581 end
580 end
582 end
581 end
583
582
584 def self.use_status_for_done_ratio?
583 def self.use_status_for_done_ratio?
585 Setting.issue_done_ratio == 'issue_status'
584 Setting.issue_done_ratio == 'issue_status'
586 end
585 end
587
586
588 def self.use_field_for_done_ratio?
587 def self.use_field_for_done_ratio?
589 Setting.issue_done_ratio == 'issue_field'
588 Setting.issue_done_ratio == 'issue_field'
590 end
589 end
591
590
592 def validate_issue
591 def validate_issue
593 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
592 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
594 errors.add :due_date, :greater_than_start_date
593 errors.add :due_date, :greater_than_start_date
595 end
594 end
596
595
597 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
596 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
598 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
597 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
599 end
598 end
600
599
601 if fixed_version
600 if fixed_version
602 if !assignable_versions.include?(fixed_version)
601 if !assignable_versions.include?(fixed_version)
603 errors.add :fixed_version_id, :inclusion
602 errors.add :fixed_version_id, :inclusion
604 elsif reopening? && fixed_version.closed?
603 elsif reopening? && fixed_version.closed?
605 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
604 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
606 end
605 end
607 end
606 end
608
607
609 # Checks that the issue can not be added/moved to a disabled tracker
608 # Checks that the issue can not be added/moved to a disabled tracker
610 if project && (tracker_id_changed? || project_id_changed?)
609 if project && (tracker_id_changed? || project_id_changed?)
611 unless project.trackers.include?(tracker)
610 unless project.trackers.include?(tracker)
612 errors.add :tracker_id, :inclusion
611 errors.add :tracker_id, :inclusion
613 end
612 end
614 end
613 end
615
614
616 # Checks parent issue assignment
615 # Checks parent issue assignment
617 if @invalid_parent_issue_id.present?
616 if @invalid_parent_issue_id.present?
618 errors.add :parent_issue_id, :invalid
617 errors.add :parent_issue_id, :invalid
619 elsif @parent_issue
618 elsif @parent_issue
620 if !valid_parent_project?(@parent_issue)
619 if !valid_parent_project?(@parent_issue)
621 errors.add :parent_issue_id, :invalid
620 errors.add :parent_issue_id, :invalid
622 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
621 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
623 errors.add :parent_issue_id, :invalid
622 errors.add :parent_issue_id, :invalid
624 elsif !new_record?
623 elsif !new_record?
625 # moving an existing issue
624 # moving an existing issue
626 if @parent_issue.root_id != root_id
625 if @parent_issue.root_id != root_id
627 # we can always move to another tree
626 # we can always move to another tree
628 elsif move_possible?(@parent_issue)
627 elsif move_possible?(@parent_issue)
629 # move accepted inside tree
628 # move accepted inside tree
630 else
629 else
631 errors.add :parent_issue_id, :invalid
630 errors.add :parent_issue_id, :invalid
632 end
631 end
633 end
632 end
634 end
633 end
635 end
634 end
636
635
637 # Validates the issue against additional workflow requirements
636 # Validates the issue against additional workflow requirements
638 def validate_required_fields
637 def validate_required_fields
639 user = new_record? ? author : current_journal.try(:user)
638 user = new_record? ? author : current_journal.try(:user)
640
639
641 required_attribute_names(user).each do |attribute|
640 required_attribute_names(user).each do |attribute|
642 if attribute =~ /^\d+$/
641 if attribute =~ /^\d+$/
643 attribute = attribute.to_i
642 attribute = attribute.to_i
644 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
643 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
645 if v && v.value.blank?
644 if v && v.value.blank?
646 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
645 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
647 end
646 end
648 else
647 else
649 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
648 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
650 errors.add attribute, :blank
649 errors.add attribute, :blank
651 end
650 end
652 end
651 end
653 end
652 end
654 end
653 end
655
654
656 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
655 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
657 # even if the user turns off the setting later
656 # even if the user turns off the setting later
658 def update_done_ratio_from_issue_status
657 def update_done_ratio_from_issue_status
659 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
658 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
660 self.done_ratio = status.default_done_ratio
659 self.done_ratio = status.default_done_ratio
661 end
660 end
662 end
661 end
663
662
664 def init_journal(user, notes = "")
663 def init_journal(user, notes = "")
665 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
664 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
666 end
665 end
667
666
668 # Returns the current journal or nil if it's not initialized
667 # Returns the current journal or nil if it's not initialized
669 def current_journal
668 def current_journal
670 @current_journal
669 @current_journal
671 end
670 end
672
671
673 # Returns the names of attributes that are journalized when updating the issue
672 # Returns the names of attributes that are journalized when updating the issue
674 def journalized_attribute_names
673 def journalized_attribute_names
675 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
674 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
676 end
675 end
677
676
678 # Returns the id of the last journal or nil
677 # Returns the id of the last journal or nil
679 def last_journal_id
678 def last_journal_id
680 if new_record?
679 if new_record?
681 nil
680 nil
682 else
681 else
683 journals.maximum(:id)
682 journals.maximum(:id)
684 end
683 end
685 end
684 end
686
685
687 # Returns a scope for journals that have an id greater than journal_id
686 # Returns a scope for journals that have an id greater than journal_id
688 def journals_after(journal_id)
687 def journals_after(journal_id)
689 scope = journals.reorder("#{Journal.table_name}.id ASC")
688 scope = journals.reorder("#{Journal.table_name}.id ASC")
690 if journal_id.present?
689 if journal_id.present?
691 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
690 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
692 end
691 end
693 scope
692 scope
694 end
693 end
695
694
696 # Returns the initial status of the issue
695 # Returns the initial status of the issue
697 # Returns nil for a new issue
696 # Returns nil for a new issue
698 def status_was
697 def status_was
699 if status_id_changed?
698 if status_id_changed?
700 if status_id_was.to_i > 0
699 if status_id_was.to_i > 0
701 @status_was ||= IssueStatus.find_by_id(status_id_was)
700 @status_was ||= IssueStatus.find_by_id(status_id_was)
702 end
701 end
703 else
702 else
704 @status_was ||= status
703 @status_was ||= status
705 end
704 end
706 end
705 end
707
706
708 # Return true if the issue is closed, otherwise false
707 # Return true if the issue is closed, otherwise false
709 def closed?
708 def closed?
710 status.present? && status.is_closed?
709 status.present? && status.is_closed?
711 end
710 end
712
711
713 # Returns true if the issue was closed when loaded
712 # Returns true if the issue was closed when loaded
714 def was_closed?
713 def was_closed?
715 status_was.present? && status_was.is_closed?
714 status_was.present? && status_was.is_closed?
716 end
715 end
717
716
718 # Return true if the issue is being reopened
717 # Return true if the issue is being reopened
719 def reopening?
718 def reopening?
720 if new_record?
719 if new_record?
721 false
720 false
722 else
721 else
723 status_id_changed? && !closed? && was_closed?
722 status_id_changed? && !closed? && was_closed?
724 end
723 end
725 end
724 end
726 alias :reopened? :reopening?
725 alias :reopened? :reopening?
727
726
728 # Return true if the issue is being closed
727 # Return true if the issue is being closed
729 def closing?
728 def closing?
730 if new_record?
729 if new_record?
731 closed?
730 closed?
732 else
731 else
733 status_id_changed? && closed? && !was_closed?
732 status_id_changed? && closed? && !was_closed?
734 end
733 end
735 end
734 end
736
735
737 # Returns true if the issue is overdue
736 # Returns true if the issue is overdue
738 def overdue?
737 def overdue?
739 due_date.present? && (due_date < Date.today) && !closed?
738 due_date.present? && (due_date < Date.today) && !closed?
740 end
739 end
741
740
742 # Is the amount of work done less than it should for the due date
741 # Is the amount of work done less than it should for the due date
743 def behind_schedule?
742 def behind_schedule?
744 return false if start_date.nil? || due_date.nil?
743 return false if start_date.nil? || due_date.nil?
745 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
744 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
746 return done_date <= Date.today
745 return done_date <= Date.today
747 end
746 end
748
747
749 # Does this issue have children?
748 # Does this issue have children?
750 def children?
749 def children?
751 !leaf?
750 !leaf?
752 end
751 end
753
752
754 # Users the issue can be assigned to
753 # Users the issue can be assigned to
755 def assignable_users
754 def assignable_users
756 users = project.assignable_users.to_a
755 users = project.assignable_users.to_a
757 users << author if author
756 users << author if author
758 users << assigned_to if assigned_to
757 users << assigned_to if assigned_to
759 users.uniq.sort
758 users.uniq.sort
760 end
759 end
761
760
762 # Versions that the issue can be assigned to
761 # Versions that the issue can be assigned to
763 def assignable_versions
762 def assignable_versions
764 return @assignable_versions if @assignable_versions
763 return @assignable_versions if @assignable_versions
765
764
766 versions = project.shared_versions.open.to_a
765 versions = project.shared_versions.open.to_a
767 if fixed_version
766 if fixed_version
768 if fixed_version_id_changed?
767 if fixed_version_id_changed?
769 # nothing to do
768 # nothing to do
770 elsif project_id_changed?
769 elsif project_id_changed?
771 if project.shared_versions.include?(fixed_version)
770 if project.shared_versions.include?(fixed_version)
772 versions << fixed_version
771 versions << fixed_version
773 end
772 end
774 else
773 else
775 versions << fixed_version
774 versions << fixed_version
776 end
775 end
777 end
776 end
778 @assignable_versions = versions.uniq.sort
777 @assignable_versions = versions.uniq.sort
779 end
778 end
780
779
781 # Returns true if this issue is blocked by another issue that is still open
780 # Returns true if this issue is blocked by another issue that is still open
782 def blocked?
781 def blocked?
783 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
782 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
784 end
783 end
785
784
786 # Returns the default status of the issue based on its tracker
785 # Returns the default status of the issue based on its tracker
787 # Returns nil if tracker is nil
786 # Returns nil if tracker is nil
788 def default_status
787 def default_status
789 tracker.try(:default_status)
788 tracker.try(:default_status)
790 end
789 end
791
790
792 # Returns an array of statuses that user is able to apply
791 # Returns an array of statuses that user is able to apply
793 def new_statuses_allowed_to(user=User.current, include_default=false)
792 def new_statuses_allowed_to(user=User.current, include_default=false)
794 if new_record? && @copied_from
793 if new_record? && @copied_from
795 [default_status, @copied_from.status].compact.uniq.sort
794 [default_status, @copied_from.status].compact.uniq.sort
796 else
795 else
797 initial_status = nil
796 initial_status = nil
798 if new_record?
797 if new_record?
799 initial_status = default_status
798 initial_status = default_status
800 elsif tracker_id_changed?
799 elsif tracker_id_changed?
801 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
800 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
802 initial_status = default_status
801 initial_status = default_status
803 elsif tracker.issue_status_ids.include?(status_id_was)
802 elsif tracker.issue_status_ids.include?(status_id_was)
804 initial_status = IssueStatus.find_by_id(status_id_was)
803 initial_status = IssueStatus.find_by_id(status_id_was)
805 else
804 else
806 initial_status = default_status
805 initial_status = default_status
807 end
806 end
808 else
807 else
809 initial_status = status_was
808 initial_status = status_was
810 end
809 end
811
810
812 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
811 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
813 assignee_transitions_allowed = initial_assigned_to_id.present? &&
812 assignee_transitions_allowed = initial_assigned_to_id.present? &&
814 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
813 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
815
814
816 statuses = []
815 statuses = []
817 if initial_status
816 if initial_status
818 statuses += initial_status.find_new_statuses_allowed_to(
817 statuses += initial_status.find_new_statuses_allowed_to(
819 user.admin ? Role.all.to_a : user.roles_for_project(project),
818 user.admin ? Role.all.to_a : user.roles_for_project(project),
820 tracker,
819 tracker,
821 author == user,
820 author == user,
822 assignee_transitions_allowed
821 assignee_transitions_allowed
823 )
822 )
824 end
823 end
825 statuses << initial_status unless statuses.empty?
824 statuses << initial_status unless statuses.empty?
826 statuses << default_status if include_default
825 statuses << default_status if include_default
827 statuses = statuses.compact.uniq.sort
826 statuses = statuses.compact.uniq.sort
828 if blocked?
827 if blocked?
829 statuses.reject!(&:is_closed?)
828 statuses.reject!(&:is_closed?)
830 end
829 end
831 statuses
830 statuses
832 end
831 end
833 end
832 end
834
833
835 # Returns the previous assignee if changed
834 # Returns the previous assignee if changed
836 def assigned_to_was
835 def assigned_to_was
837 # assigned_to_id_was is reset before after_save callbacks
836 # assigned_to_id_was is reset before after_save callbacks
838 user_id = @previous_assigned_to_id || assigned_to_id_was
837 user_id = @previous_assigned_to_id || assigned_to_id_was
839 if user_id && user_id != assigned_to_id
838 if user_id && user_id != assigned_to_id
840 @assigned_to_was ||= User.find_by_id(user_id)
839 @assigned_to_was ||= User.find_by_id(user_id)
841 end
840 end
842 end
841 end
843
842
844 # Returns the users that should be notified
843 # Returns the users that should be notified
845 def notified_users
844 def notified_users
846 notified = []
845 notified = []
847 # Author and assignee are always notified unless they have been
846 # Author and assignee are always notified unless they have been
848 # locked or don't want to be notified
847 # locked or don't want to be notified
849 notified << author if author
848 notified << author if author
850 if assigned_to
849 if assigned_to
851 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
850 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
852 end
851 end
853 if assigned_to_was
852 if assigned_to_was
854 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
853 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
855 end
854 end
856 notified = notified.select {|u| u.active? && u.notify_about?(self)}
855 notified = notified.select {|u| u.active? && u.notify_about?(self)}
857
856
858 notified += project.notified_users
857 notified += project.notified_users
859 notified.uniq!
858 notified.uniq!
860 # Remove users that can not view the issue
859 # Remove users that can not view the issue
861 notified.reject! {|user| !visible?(user)}
860 notified.reject! {|user| !visible?(user)}
862 notified
861 notified
863 end
862 end
864
863
865 # Returns the email addresses that should be notified
864 # Returns the email addresses that should be notified
866 def recipients
865 def recipients
867 notified_users.collect(&:mail)
866 notified_users.collect(&:mail)
868 end
867 end
869
868
870 def each_notification(users, &block)
869 def each_notification(users, &block)
871 if users.any?
870 if users.any?
872 if custom_field_values.detect {|value| !value.custom_field.visible?}
871 if custom_field_values.detect {|value| !value.custom_field.visible?}
873 users_by_custom_field_visibility = users.group_by do |user|
872 users_by_custom_field_visibility = users.group_by do |user|
874 visible_custom_field_values(user).map(&:custom_field_id).sort
873 visible_custom_field_values(user).map(&:custom_field_id).sort
875 end
874 end
876 users_by_custom_field_visibility.values.each do |users|
875 users_by_custom_field_visibility.values.each do |users|
877 yield(users)
876 yield(users)
878 end
877 end
879 else
878 else
880 yield(users)
879 yield(users)
881 end
880 end
882 end
881 end
883 end
882 end
884
883
885 # Returns the number of hours spent on this issue
884 # Returns the number of hours spent on this issue
886 def spent_hours
885 def spent_hours
887 @spent_hours ||= time_entries.sum(:hours) || 0
886 @spent_hours ||= time_entries.sum(:hours) || 0
888 end
887 end
889
888
890 # Returns the total number of hours spent on this issue and its descendants
889 # Returns the total number of hours spent on this issue and its descendants
891 #
890 #
892 # Example:
891 # Example:
893 # spent_hours => 0.0
892 # spent_hours => 0.0
894 # spent_hours => 50.2
893 # spent_hours => 50.2
895 def total_spent_hours
894 def total_spent_hours
896 @total_spent_hours ||=
895 @total_spent_hours ||=
897 self_and_descendants.
896 self_and_descendants.
898 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
897 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
899 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
898 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
900 end
899 end
901
900
902 def relations
901 def relations
903 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
902 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
904 end
903 end
905
904
906 # Preloads relations for a collection of issues
905 # Preloads relations for a collection of issues
907 def self.load_relations(issues)
906 def self.load_relations(issues)
908 if issues.any?
907 if issues.any?
909 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
908 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
910 issues.each do |issue|
909 issues.each do |issue|
911 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
910 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
912 end
911 end
913 end
912 end
914 end
913 end
915
914
916 # Preloads visible spent time for a collection of issues
915 # Preloads visible spent time for a collection of issues
917 def self.load_visible_spent_hours(issues, user=User.current)
916 def self.load_visible_spent_hours(issues, user=User.current)
918 if issues.any?
917 if issues.any?
919 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
918 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
920 issues.each do |issue|
919 issues.each do |issue|
921 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
920 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
922 end
921 end
923 end
922 end
924 end
923 end
925
924
926 # Preloads visible relations for a collection of issues
925 # Preloads visible relations for a collection of issues
927 def self.load_visible_relations(issues, user=User.current)
926 def self.load_visible_relations(issues, user=User.current)
928 if issues.any?
927 if issues.any?
929 issue_ids = issues.map(&:id)
928 issue_ids = issues.map(&:id)
930 # Relations with issue_from in given issues and visible issue_to
929 # Relations with issue_from in given issues and visible issue_to
931 relations_from = IssueRelation.joins(:issue_to => :project).
930 relations_from = IssueRelation.joins(:issue_to => :project).
932 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
931 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
933 # Relations with issue_to in given issues and visible issue_from
932 # Relations with issue_to in given issues and visible issue_from
934 relations_to = IssueRelation.joins(:issue_from => :project).
933 relations_to = IssueRelation.joins(:issue_from => :project).
935 where(visible_condition(user)).
934 where(visible_condition(user)).
936 where(:issue_to_id => issue_ids).to_a
935 where(:issue_to_id => issue_ids).to_a
937 issues.each do |issue|
936 issues.each do |issue|
938 relations =
937 relations =
939 relations_from.select {|relation| relation.issue_from_id == issue.id} +
938 relations_from.select {|relation| relation.issue_from_id == issue.id} +
940 relations_to.select {|relation| relation.issue_to_id == issue.id}
939 relations_to.select {|relation| relation.issue_to_id == issue.id}
941
940
942 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
941 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
943 end
942 end
944 end
943 end
945 end
944 end
946
945
947 # Finds an issue relation given its id.
946 # Finds an issue relation given its id.
948 def find_relation(relation_id)
947 def find_relation(relation_id)
949 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
948 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
950 end
949 end
951
950
952 # Returns all the other issues that depend on the issue
951 # Returns all the other issues that depend on the issue
953 # The algorithm is a modified breadth first search (bfs)
952 # The algorithm is a modified breadth first search (bfs)
954 def all_dependent_issues(except=[])
953 def all_dependent_issues(except=[])
955 # The found dependencies
954 # The found dependencies
956 dependencies = []
955 dependencies = []
957
956
958 # The visited flag for every node (issue) used by the breadth first search
957 # The visited flag for every node (issue) used by the breadth first search
959 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
958 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
960
959
961 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
960 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
962 # the issue when it is processed.
961 # the issue when it is processed.
963
962
964 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
963 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
965 # but its children will not be added to the queue when it is processed.
964 # but its children will not be added to the queue when it is processed.
966
965
967 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
966 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
968 # the queue, but its children have not been added.
967 # the queue, but its children have not been added.
969
968
970 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
969 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
971 # the children still need to be processed.
970 # the children still need to be processed.
972
971
973 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
972 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
974 # added as dependent issues. It needs no further processing.
973 # added as dependent issues. It needs no further processing.
975
974
976 issue_status = Hash.new(eNOT_DISCOVERED)
975 issue_status = Hash.new(eNOT_DISCOVERED)
977
976
978 # The queue
977 # The queue
979 queue = []
978 queue = []
980
979
981 # Initialize the bfs, add start node (self) to the queue
980 # Initialize the bfs, add start node (self) to the queue
982 queue << self
981 queue << self
983 issue_status[self] = ePROCESS_ALL
982 issue_status[self] = ePROCESS_ALL
984
983
985 while (!queue.empty?) do
984 while (!queue.empty?) do
986 current_issue = queue.shift
985 current_issue = queue.shift
987 current_issue_status = issue_status[current_issue]
986 current_issue_status = issue_status[current_issue]
988 dependencies << current_issue
987 dependencies << current_issue
989
988
990 # Add parent to queue, if not already in it.
989 # Add parent to queue, if not already in it.
991 parent = current_issue.parent
990 parent = current_issue.parent
992 parent_status = issue_status[parent]
991 parent_status = issue_status[parent]
993
992
994 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
993 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
995 queue << parent
994 queue << parent
996 issue_status[parent] = ePROCESS_RELATIONS_ONLY
995 issue_status[parent] = ePROCESS_RELATIONS_ONLY
997 end
996 end
998
997
999 # Add children to queue, but only if they are not already in it and
998 # Add children to queue, but only if they are not already in it and
1000 # the children of the current node need to be processed.
999 # the children of the current node need to be processed.
1001 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1000 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1002 current_issue.children.each do |child|
1001 current_issue.children.each do |child|
1003 next if except.include?(child)
1002 next if except.include?(child)
1004
1003
1005 if (issue_status[child] == eNOT_DISCOVERED)
1004 if (issue_status[child] == eNOT_DISCOVERED)
1006 queue << child
1005 queue << child
1007 issue_status[child] = ePROCESS_ALL
1006 issue_status[child] = ePROCESS_ALL
1008 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1007 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1009 queue << child
1008 queue << child
1010 issue_status[child] = ePROCESS_CHILDREN_ONLY
1009 issue_status[child] = ePROCESS_CHILDREN_ONLY
1011 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1010 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1012 queue << child
1011 queue << child
1013 issue_status[child] = ePROCESS_ALL
1012 issue_status[child] = ePROCESS_ALL
1014 end
1013 end
1015 end
1014 end
1016 end
1015 end
1017
1016
1018 # Add related issues to the queue, if they are not already in it.
1017 # Add related issues to the queue, if they are not already in it.
1019 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1018 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1020 next if except.include?(related_issue)
1019 next if except.include?(related_issue)
1021
1020
1022 if (issue_status[related_issue] == eNOT_DISCOVERED)
1021 if (issue_status[related_issue] == eNOT_DISCOVERED)
1023 queue << related_issue
1022 queue << related_issue
1024 issue_status[related_issue] = ePROCESS_ALL
1023 issue_status[related_issue] = ePROCESS_ALL
1025 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1024 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1026 queue << related_issue
1025 queue << related_issue
1027 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1026 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1028 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1027 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1029 queue << related_issue
1028 queue << related_issue
1030 issue_status[related_issue] = ePROCESS_ALL
1029 issue_status[related_issue] = ePROCESS_ALL
1031 end
1030 end
1032 end
1031 end
1033
1032
1034 # Set new status for current issue
1033 # Set new status for current issue
1035 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1034 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1036 issue_status[current_issue] = eALL_PROCESSED
1035 issue_status[current_issue] = eALL_PROCESSED
1037 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1036 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1038 issue_status[current_issue] = eRELATIONS_PROCESSED
1037 issue_status[current_issue] = eRELATIONS_PROCESSED
1039 end
1038 end
1040 end # while
1039 end # while
1041
1040
1042 # Remove the issues from the "except" parameter from the result array
1041 # Remove the issues from the "except" parameter from the result array
1043 dependencies -= except
1042 dependencies -= except
1044 dependencies.delete(self)
1043 dependencies.delete(self)
1045
1044
1046 dependencies
1045 dependencies
1047 end
1046 end
1048
1047
1049 # Returns an array of issues that duplicate this one
1048 # Returns an array of issues that duplicate this one
1050 def duplicates
1049 def duplicates
1051 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1050 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1052 end
1051 end
1053
1052
1054 # Returns the due date or the target due date if any
1053 # Returns the due date or the target due date if any
1055 # Used on gantt chart
1054 # Used on gantt chart
1056 def due_before
1055 def due_before
1057 due_date || (fixed_version ? fixed_version.effective_date : nil)
1056 due_date || (fixed_version ? fixed_version.effective_date : nil)
1058 end
1057 end
1059
1058
1060 # Returns the time scheduled for this issue.
1059 # Returns the time scheduled for this issue.
1061 #
1060 #
1062 # Example:
1061 # Example:
1063 # Start Date: 2/26/09, End Date: 3/04/09
1062 # Start Date: 2/26/09, End Date: 3/04/09
1064 # duration => 6
1063 # duration => 6
1065 def duration
1064 def duration
1066 (start_date && due_date) ? due_date - start_date : 0
1065 (start_date && due_date) ? due_date - start_date : 0
1067 end
1066 end
1068
1067
1069 # Returns the duration in working days
1068 # Returns the duration in working days
1070 def working_duration
1069 def working_duration
1071 (start_date && due_date) ? working_days(start_date, due_date) : 0
1070 (start_date && due_date) ? working_days(start_date, due_date) : 0
1072 end
1071 end
1073
1072
1074 def soonest_start(reload=false)
1073 def soonest_start(reload=false)
1075 @soonest_start = nil if reload
1074 @soonest_start = nil if reload
1076 @soonest_start ||= (
1075 @soonest_start ||= (
1077 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1076 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1078 [(@parent_issue || parent).try(:soonest_start)]
1077 [(@parent_issue || parent).try(:soonest_start)]
1079 ).compact.max
1078 ).compact.max
1080 end
1079 end
1081
1080
1082 # Sets start_date on the given date or the next working day
1081 # Sets start_date on the given date or the next working day
1083 # and changes due_date to keep the same working duration.
1082 # and changes due_date to keep the same working duration.
1084 def reschedule_on(date)
1083 def reschedule_on(date)
1085 wd = working_duration
1084 wd = working_duration
1086 date = next_working_date(date)
1085 date = next_working_date(date)
1087 self.start_date = date
1086 self.start_date = date
1088 self.due_date = add_working_days(date, wd)
1087 self.due_date = add_working_days(date, wd)
1089 end
1088 end
1090
1089
1091 # Reschedules the issue on the given date or the next working day and saves the record.
1090 # Reschedules the issue on the given date or the next working day and saves the record.
1092 # If the issue is a parent task, this is done by rescheduling its subtasks.
1091 # If the issue is a parent task, this is done by rescheduling its subtasks.
1093 def reschedule_on!(date)
1092 def reschedule_on!(date)
1094 return if date.nil?
1093 return if date.nil?
1095 if leaf?
1094 if leaf?
1096 if start_date.nil? || start_date != date
1095 if start_date.nil? || start_date != date
1097 if start_date && start_date > date
1096 if start_date && start_date > date
1098 # Issue can not be moved earlier than its soonest start date
1097 # Issue can not be moved earlier than its soonest start date
1099 date = [soonest_start(true), date].compact.max
1098 date = [soonest_start(true), date].compact.max
1100 end
1099 end
1101 reschedule_on(date)
1100 reschedule_on(date)
1102 begin
1101 begin
1103 save
1102 save
1104 rescue ActiveRecord::StaleObjectError
1103 rescue ActiveRecord::StaleObjectError
1105 reload
1104 reload
1106 reschedule_on(date)
1105 reschedule_on(date)
1107 save
1106 save
1108 end
1107 end
1109 end
1108 end
1110 else
1109 else
1111 leaves.each do |leaf|
1110 leaves.each do |leaf|
1112 if leaf.start_date
1111 if leaf.start_date
1113 # Only move subtask if it starts at the same date as the parent
1112 # Only move subtask if it starts at the same date as the parent
1114 # or if it starts before the given date
1113 # or if it starts before the given date
1115 if start_date == leaf.start_date || date > leaf.start_date
1114 if start_date == leaf.start_date || date > leaf.start_date
1116 leaf.reschedule_on!(date)
1115 leaf.reschedule_on!(date)
1117 end
1116 end
1118 else
1117 else
1119 leaf.reschedule_on!(date)
1118 leaf.reschedule_on!(date)
1120 end
1119 end
1121 end
1120 end
1122 end
1121 end
1123 end
1122 end
1124
1123
1125 def <=>(issue)
1124 def <=>(issue)
1126 if issue.nil?
1125 if issue.nil?
1127 -1
1126 -1
1128 elsif root_id != issue.root_id
1127 elsif root_id != issue.root_id
1129 (root_id || 0) <=> (issue.root_id || 0)
1128 (root_id || 0) <=> (issue.root_id || 0)
1130 else
1129 else
1131 (lft || 0) <=> (issue.lft || 0)
1130 (lft || 0) <=> (issue.lft || 0)
1132 end
1131 end
1133 end
1132 end
1134
1133
1135 def to_s
1134 def to_s
1136 "#{tracker} ##{id}: #{subject}"
1135 "#{tracker} ##{id}: #{subject}"
1137 end
1136 end
1138
1137
1139 # Returns a string of css classes that apply to the issue
1138 # Returns a string of css classes that apply to the issue
1140 def css_classes(user=User.current)
1139 def css_classes(user=User.current)
1141 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1140 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1142 s << ' closed' if closed?
1141 s << ' closed' if closed?
1143 s << ' overdue' if overdue?
1142 s << ' overdue' if overdue?
1144 s << ' child' if child?
1143 s << ' child' if child?
1145 s << ' parent' unless leaf?
1144 s << ' parent' unless leaf?
1146 s << ' private' if is_private?
1145 s << ' private' if is_private?
1147 if user.logged?
1146 if user.logged?
1148 s << ' created-by-me' if author_id == user.id
1147 s << ' created-by-me' if author_id == user.id
1149 s << ' assigned-to-me' if assigned_to_id == user.id
1148 s << ' assigned-to-me' if assigned_to_id == user.id
1150 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1149 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1151 end
1150 end
1152 s
1151 s
1153 end
1152 end
1154
1153
1155 # Unassigns issues from +version+ if it's no longer shared with issue's project
1154 # Unassigns issues from +version+ if it's no longer shared with issue's project
1156 def self.update_versions_from_sharing_change(version)
1155 def self.update_versions_from_sharing_change(version)
1157 # Update issues assigned to the version
1156 # Update issues assigned to the version
1158 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1157 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1159 end
1158 end
1160
1159
1161 # Unassigns issues from versions that are no longer shared
1160 # Unassigns issues from versions that are no longer shared
1162 # after +project+ was moved
1161 # after +project+ was moved
1163 def self.update_versions_from_hierarchy_change(project)
1162 def self.update_versions_from_hierarchy_change(project)
1164 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1163 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1165 # Update issues of the moved projects and issues assigned to a version of a moved project
1164 # Update issues of the moved projects and issues assigned to a version of a moved project
1166 Issue.update_versions(
1165 Issue.update_versions(
1167 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1166 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1168 moved_project_ids, moved_project_ids]
1167 moved_project_ids, moved_project_ids]
1169 )
1168 )
1170 end
1169 end
1171
1170
1172 def parent_issue_id=(arg)
1171 def parent_issue_id=(arg)
1173 s = arg.to_s.strip.presence
1172 s = arg.to_s.strip.presence
1174 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1173 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1175 @invalid_parent_issue_id = nil
1174 @invalid_parent_issue_id = nil
1176 elsif s.blank?
1175 elsif s.blank?
1177 @parent_issue = nil
1176 @parent_issue = nil
1178 @invalid_parent_issue_id = nil
1177 @invalid_parent_issue_id = nil
1179 else
1178 else
1180 @parent_issue = nil
1179 @parent_issue = nil
1181 @invalid_parent_issue_id = arg
1180 @invalid_parent_issue_id = arg
1182 end
1181 end
1183 end
1182 end
1184
1183
1185 def parent_issue_id
1184 def parent_issue_id
1186 if @invalid_parent_issue_id
1185 if @invalid_parent_issue_id
1187 @invalid_parent_issue_id
1186 @invalid_parent_issue_id
1188 elsif instance_variable_defined? :@parent_issue
1187 elsif instance_variable_defined? :@parent_issue
1189 @parent_issue.nil? ? nil : @parent_issue.id
1188 @parent_issue.nil? ? nil : @parent_issue.id
1190 else
1189 else
1191 parent_id
1190 parent_id
1192 end
1191 end
1193 end
1192 end
1194
1193
1195 # Returns true if issue's project is a valid
1194 # Returns true if issue's project is a valid
1196 # parent issue project
1195 # parent issue project
1197 def valid_parent_project?(issue=parent)
1196 def valid_parent_project?(issue=parent)
1198 return true if issue.nil? || issue.project_id == project_id
1197 return true if issue.nil? || issue.project_id == project_id
1199
1198
1200 case Setting.cross_project_subtasks
1199 case Setting.cross_project_subtasks
1201 when 'system'
1200 when 'system'
1202 true
1201 true
1203 when 'tree'
1202 when 'tree'
1204 issue.project.root == project.root
1203 issue.project.root == project.root
1205 when 'hierarchy'
1204 when 'hierarchy'
1206 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1205 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1207 when 'descendants'
1206 when 'descendants'
1208 issue.project.is_or_is_ancestor_of?(project)
1207 issue.project.is_or_is_ancestor_of?(project)
1209 else
1208 else
1210 false
1209 false
1211 end
1210 end
1212 end
1211 end
1213
1212
1214 # Returns an issue scope based on project and scope
1213 # Returns an issue scope based on project and scope
1215 def self.cross_project_scope(project, scope=nil)
1214 def self.cross_project_scope(project, scope=nil)
1216 if project.nil?
1215 if project.nil?
1217 return Issue
1216 return Issue
1218 end
1217 end
1219 case scope
1218 case scope
1220 when 'all', 'system'
1219 when 'all', 'system'
1221 Issue
1220 Issue
1222 when 'tree'
1221 when 'tree'
1223 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1222 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1224 :lft => project.root.lft, :rgt => project.root.rgt)
1223 :lft => project.root.lft, :rgt => project.root.rgt)
1225 when 'hierarchy'
1224 when 'hierarchy'
1226 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1225 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1227 :lft => project.lft, :rgt => project.rgt)
1226 :lft => project.lft, :rgt => project.rgt)
1228 when 'descendants'
1227 when 'descendants'
1229 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1228 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1230 :lft => project.lft, :rgt => project.rgt)
1229 :lft => project.lft, :rgt => project.rgt)
1231 else
1230 else
1232 Issue.where(:project_id => project.id)
1231 Issue.where(:project_id => project.id)
1233 end
1232 end
1234 end
1233 end
1235
1234
1236 def self.by_tracker(project)
1235 def self.by_tracker(project)
1237 count_and_group_by(:project => project, :association => :tracker)
1236 count_and_group_by(:project => project, :association => :tracker)
1238 end
1237 end
1239
1238
1240 def self.by_version(project)
1239 def self.by_version(project)
1241 count_and_group_by(:project => project, :association => :fixed_version)
1240 count_and_group_by(:project => project, :association => :fixed_version)
1242 end
1241 end
1243
1242
1244 def self.by_priority(project)
1243 def self.by_priority(project)
1245 count_and_group_by(:project => project, :association => :priority)
1244 count_and_group_by(:project => project, :association => :priority)
1246 end
1245 end
1247
1246
1248 def self.by_category(project)
1247 def self.by_category(project)
1249 count_and_group_by(:project => project, :association => :category)
1248 count_and_group_by(:project => project, :association => :category)
1250 end
1249 end
1251
1250
1252 def self.by_assigned_to(project)
1251 def self.by_assigned_to(project)
1253 count_and_group_by(:project => project, :association => :assigned_to)
1252 count_and_group_by(:project => project, :association => :assigned_to)
1254 end
1253 end
1255
1254
1256 def self.by_author(project)
1255 def self.by_author(project)
1257 count_and_group_by(:project => project, :association => :author)
1256 count_and_group_by(:project => project, :association => :author)
1258 end
1257 end
1259
1258
1260 def self.by_subproject(project)
1259 def self.by_subproject(project)
1261 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1260 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1262 r.reject {|r| r["project_id"] == project.id.to_s}
1261 r.reject {|r| r["project_id"] == project.id.to_s}
1263 end
1262 end
1264
1263
1265 # Query generator for selecting groups of issue counts for a project
1264 # Query generator for selecting groups of issue counts for a project
1266 # based on specific criteria
1265 # based on specific criteria
1267 #
1266 #
1268 # Options
1267 # Options
1269 # * project - Project to search in.
1268 # * project - Project to search in.
1270 # * with_subprojects - Includes subprojects issues if set to true.
1269 # * with_subprojects - Includes subprojects issues if set to true.
1271 # * association - Symbol. Association for grouping.
1270 # * association - Symbol. Association for grouping.
1272 def self.count_and_group_by(options)
1271 def self.count_and_group_by(options)
1273 assoc = reflect_on_association(options[:association])
1272 assoc = reflect_on_association(options[:association])
1274 select_field = assoc.foreign_key
1273 select_field = assoc.foreign_key
1275
1274
1276 Issue.
1275 Issue.
1277 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1276 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1278 joins(:status, assoc.name).
1277 joins(:status, assoc.name).
1279 group(:status_id, :is_closed, select_field).
1278 group(:status_id, :is_closed, select_field).
1280 count.
1279 count.
1281 map do |columns, total|
1280 map do |columns, total|
1282 status_id, is_closed, field_value = columns
1281 status_id, is_closed, field_value = columns
1283 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1282 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1284 {
1283 {
1285 "status_id" => status_id.to_s,
1284 "status_id" => status_id.to_s,
1286 "closed" => is_closed,
1285 "closed" => is_closed,
1287 select_field => field_value.to_s,
1286 select_field => field_value.to_s,
1288 "total" => total.to_s
1287 "total" => total.to_s
1289 }
1288 }
1290 end
1289 end
1291 end
1290 end
1292
1291
1293 # Returns a scope of projects that user can assign the issue to
1292 # Returns a scope of projects that user can assign the issue to
1294 def allowed_target_projects(user=User.current)
1293 def allowed_target_projects(user=User.current)
1295 if new_record?
1294 if new_record?
1296 Project.where(Project.allowed_to_condition(user, :add_issues))
1295 Project.where(Project.allowed_to_condition(user, :add_issues))
1297 else
1296 else
1298 self.class.allowed_target_projects_on_move(user)
1297 self.class.allowed_target_projects_on_move(user)
1299 end
1298 end
1300 end
1299 end
1301
1300
1302 # Returns a scope of projects that user can move issues to
1301 # Returns a scope of projects that user can move issues to
1303 def self.allowed_target_projects_on_move(user=User.current)
1302 def self.allowed_target_projects_on_move(user=User.current)
1304 Project.where(Project.allowed_to_condition(user, :move_issues))
1303 Project.where(Project.allowed_to_condition(user, :move_issues))
1305 end
1304 end
1306
1305
1307 private
1306 private
1308
1307
1309 def after_project_change
1308 def after_project_change
1310 # Update project_id on related time entries
1309 # Update project_id on related time entries
1311 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1310 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1312
1311
1313 # Delete issue relations
1312 # Delete issue relations
1314 unless Setting.cross_project_issue_relations?
1313 unless Setting.cross_project_issue_relations?
1315 relations_from.clear
1314 relations_from.clear
1316 relations_to.clear
1315 relations_to.clear
1317 end
1316 end
1318
1317
1319 # Move subtasks that were in the same project
1318 # Move subtasks that were in the same project
1320 children.each do |child|
1319 children.each do |child|
1321 next unless child.project_id == project_id_was
1320 next unless child.project_id == project_id_was
1322 # Change project and keep project
1321 # Change project and keep project
1323 child.send :project=, project, true
1322 child.send :project=, project, true
1324 unless child.save
1323 unless child.save
1325 raise ActiveRecord::Rollback
1324 raise ActiveRecord::Rollback
1326 end
1325 end
1327 end
1326 end
1328 end
1327 end
1329
1328
1330 # Callback for after the creation of an issue by copy
1329 # Callback for after the creation of an issue by copy
1331 # * adds a "copied to" relation with the copied issue
1330 # * adds a "copied to" relation with the copied issue
1332 # * copies subtasks from the copied issue
1331 # * copies subtasks from the copied issue
1333 def after_create_from_copy
1332 def after_create_from_copy
1334 return unless copy? && !@after_create_from_copy_handled
1333 return unless copy? && !@after_create_from_copy_handled
1335
1334
1336 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1335 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1337 if @current_journal
1336 if @current_journal
1338 @copied_from.init_journal(@current_journal.user)
1337 @copied_from.init_journal(@current_journal.user)
1339 end
1338 end
1340 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1339 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1341 unless relation.save
1340 unless relation.save
1342 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1341 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1343 end
1342 end
1344 end
1343 end
1345
1344
1346 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1345 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1347 copy_options = (@copy_options || {}).merge(:subtasks => false)
1346 copy_options = (@copy_options || {}).merge(:subtasks => false)
1348 copied_issue_ids = {@copied_from.id => self.id}
1347 copied_issue_ids = {@copied_from.id => self.id}
1349 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1348 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1350 # Do not copy self when copying an issue as a descendant of the copied issue
1349 # Do not copy self when copying an issue as a descendant of the copied issue
1351 next if child == self
1350 next if child == self
1352 # Do not copy subtasks of issues that were not copied
1351 # Do not copy subtasks of issues that were not copied
1353 next unless copied_issue_ids[child.parent_id]
1352 next unless copied_issue_ids[child.parent_id]
1354 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1353 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1355 unless child.visible?
1354 unless child.visible?
1356 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1355 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1357 next
1356 next
1358 end
1357 end
1359 copy = Issue.new.copy_from(child, copy_options)
1358 copy = Issue.new.copy_from(child, copy_options)
1360 if @current_journal
1359 if @current_journal
1361 copy.init_journal(@current_journal.user)
1360 copy.init_journal(@current_journal.user)
1362 end
1361 end
1363 copy.author = author
1362 copy.author = author
1364 copy.project = project
1363 copy.project = project
1365 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1364 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1366 unless copy.save
1365 unless copy.save
1367 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
1366 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
1368 next
1367 next
1369 end
1368 end
1370 copied_issue_ids[child.id] = copy.id
1369 copied_issue_ids[child.id] = copy.id
1371 end
1370 end
1372 end
1371 end
1373 @after_create_from_copy_handled = true
1372 @after_create_from_copy_handled = true
1374 end
1373 end
1375
1374
1376 def update_nested_set_attributes
1375 def update_nested_set_attributes
1377 if root_id.nil?
1376 if root_id.nil?
1378 # issue was just created
1377 # issue was just created
1379 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1378 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1380 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1379 Issue.where(["id = ?", id]).update_all(["root_id = ?", root_id])
1381 if @parent_issue
1380 if @parent_issue
1382 move_to_child_of(@parent_issue)
1381 move_to_child_of(@parent_issue)
1383 end
1382 end
1384 elsif parent_issue_id != parent_id
1383 elsif parent_issue_id != parent_id
1385 update_nested_set_attributes_on_parent_change
1384 update_nested_set_attributes_on_parent_change
1386 end
1385 end
1387 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1386 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1388 end
1387 end
1389
1388
1390 # Updates the nested set for when an existing issue is moved
1389 # Updates the nested set for when an existing issue is moved
1391 def update_nested_set_attributes_on_parent_change
1390 def update_nested_set_attributes_on_parent_change
1392 former_parent_id = parent_id
1391 former_parent_id = parent_id
1393 # moving an existing issue
1392 # moving an existing issue
1394 if @parent_issue && @parent_issue.root_id == root_id
1393 if @parent_issue && @parent_issue.root_id == root_id
1395 # inside the same tree
1394 # inside the same tree
1396 move_to_child_of(@parent_issue)
1395 move_to_child_of(@parent_issue)
1397 else
1396 else
1398 # to another tree
1397 # to another tree
1399 unless root?
1398 unless root?
1400 move_to_right_of(root)
1399 move_to_right_of(root)
1401 end
1400 end
1402 old_root_id = root_id
1401 old_root_id = root_id
1403 in_tenacious_transaction do
1402 in_tenacious_transaction do
1404 @parent_issue.reload_nested_set if @parent_issue
1403 @parent_issue.reload_nested_set if @parent_issue
1405 self.reload_nested_set
1404 self.reload_nested_set
1406 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1405 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1407 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1406 cond = ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt]
1408 self.class.base_class.select('id').lock(true).where(cond)
1407 self.class.base_class.select('id').lock(true).where(cond)
1409 offset = rdm_right_most_bound + 1 - lft
1408 offset = rdm_right_most_bound + 1 - lft
1410 Issue.where(cond).
1409 Issue.where(cond).
1411 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1410 update_all(["root_id = ?, lft = lft + ?, rgt = rgt + ?", root_id, offset, offset])
1412 end
1411 end
1413 if @parent_issue
1412 if @parent_issue
1414 move_to_child_of(@parent_issue)
1413 move_to_child_of(@parent_issue)
1415 end
1414 end
1416 end
1415 end
1417 # delete invalid relations of all descendants
1416 # delete invalid relations of all descendants
1418 self_and_descendants.each do |issue|
1417 self_and_descendants.each do |issue|
1419 issue.relations.each do |relation|
1418 issue.relations.each do |relation|
1420 relation.destroy unless relation.valid?
1419 relation.destroy unless relation.valid?
1421 end
1420 end
1422 end
1421 end
1423 # update former parent
1422 # update former parent
1424 recalculate_attributes_for(former_parent_id) if former_parent_id
1423 recalculate_attributes_for(former_parent_id) if former_parent_id
1425 end
1424 end
1426
1425
1427 def rdm_right_most_bound
1426 def rdm_right_most_bound
1428 right_most_node =
1427 right_most_node =
1429 self.class.base_class.unscoped.
1428 self.class.base_class.unscoped.
1430 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
1429 order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first
1431 right_most_node ? (right_most_node[right_column_name] || 0 ) : 0
1430 right_most_node ? (right_most_node[right_column_name] || 0 ) : 0
1432 end
1431 end
1433 private :rdm_right_most_bound
1432 private :rdm_right_most_bound
1434
1433
1435 def update_parent_attributes
1434 def update_parent_attributes
1436 recalculate_attributes_for(parent_id) if parent_id
1435 recalculate_attributes_for(parent_id) if parent_id
1437 end
1436 end
1438
1437
1439 def recalculate_attributes_for(issue_id)
1438 def recalculate_attributes_for(issue_id)
1440 if issue_id && p = Issue.find_by_id(issue_id)
1439 if issue_id && p = Issue.find_by_id(issue_id)
1441 # priority = highest priority of children
1440 # priority = highest priority of children
1442 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1441 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1443 p.priority = IssuePriority.find_by_position(priority_position)
1442 p.priority = IssuePriority.find_by_position(priority_position)
1444 end
1443 end
1445
1444
1446 # start/due dates = lowest/highest dates of children
1445 # start/due dates = lowest/highest dates of children
1447 p.start_date = p.children.minimum(:start_date)
1446 p.start_date = p.children.minimum(:start_date)
1448 p.due_date = p.children.maximum(:due_date)
1447 p.due_date = p.children.maximum(:due_date)
1449 if p.start_date && p.due_date && p.due_date < p.start_date
1448 if p.start_date && p.due_date && p.due_date < p.start_date
1450 p.start_date, p.due_date = p.due_date, p.start_date
1449 p.start_date, p.due_date = p.due_date, p.start_date
1451 end
1450 end
1452
1451
1453 # done ratio = weighted average ratio of leaves
1452 # done ratio = weighted average ratio of leaves
1454 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1453 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1455 leaves_count = p.leaves.count
1454 leaves_count = p.leaves.count
1456 if leaves_count > 0
1455 if leaves_count > 0
1457 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1456 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1458 if average == 0
1457 if average == 0
1459 average = 1
1458 average = 1
1460 end
1459 end
1461 done = p.leaves.joins(:status).
1460 done = p.leaves.joins(:status).
1462 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1461 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1463 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1462 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1464 progress = done / (average * leaves_count)
1463 progress = done / (average * leaves_count)
1465 p.done_ratio = progress.round
1464 p.done_ratio = progress.round
1466 end
1465 end
1467 end
1466 end
1468
1467
1469 # estimate = sum of leaves estimates
1468 # estimate = sum of leaves estimates
1470 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1469 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1471 p.estimated_hours = nil if p.estimated_hours == 0.0
1470 p.estimated_hours = nil if p.estimated_hours == 0.0
1472
1471
1473 # ancestors will be recursively updated
1472 # ancestors will be recursively updated
1474 p.save(:validate => false)
1473 p.save(:validate => false)
1475 end
1474 end
1476 end
1475 end
1477
1476
1478 # Update issues so their versions are not pointing to a
1477 # Update issues so their versions are not pointing to a
1479 # fixed_version that is not shared with the issue's project
1478 # fixed_version that is not shared with the issue's project
1480 def self.update_versions(conditions=nil)
1479 def self.update_versions(conditions=nil)
1481 # Only need to update issues with a fixed_version from
1480 # Only need to update issues with a fixed_version from
1482 # a different project and that is not systemwide shared
1481 # a different project and that is not systemwide shared
1483 Issue.joins(:project, :fixed_version).
1482 Issue.joins(:project, :fixed_version).
1484 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1483 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1485 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1484 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1486 " AND #{Version.table_name}.sharing <> 'system'").
1485 " AND #{Version.table_name}.sharing <> 'system'").
1487 where(conditions).each do |issue|
1486 where(conditions).each do |issue|
1488 next if issue.project.nil? || issue.fixed_version.nil?
1487 next if issue.project.nil? || issue.fixed_version.nil?
1489 unless issue.project.shared_versions.include?(issue.fixed_version)
1488 unless issue.project.shared_versions.include?(issue.fixed_version)
1490 issue.init_journal(User.current)
1489 issue.init_journal(User.current)
1491 issue.fixed_version = nil
1490 issue.fixed_version = nil
1492 issue.save
1491 issue.save
1493 end
1492 end
1494 end
1493 end
1495 end
1494 end
1496
1495
1497 # Callback on file attachment
1496 # Callback on file attachment
1498 def attachment_added(attachment)
1497 def attachment_added(attachment)
1499 if current_journal && !attachment.new_record?
1498 if current_journal && !attachment.new_record?
1500 current_journal.journalize_attachment(attachment, :added)
1499 current_journal.journalize_attachment(attachment, :added)
1501 end
1500 end
1502 end
1501 end
1503
1502
1504 # Callback on attachment deletion
1503 # Callback on attachment deletion
1505 def attachment_removed(attachment)
1504 def attachment_removed(attachment)
1506 if current_journal && !attachment.new_record?
1505 if current_journal && !attachment.new_record?
1507 current_journal.journalize_attachment(attachment, :removed)
1506 current_journal.journalize_attachment(attachment, :removed)
1508 current_journal.save
1507 current_journal.save
1509 end
1508 end
1510 end
1509 end
1511
1510
1512 # Called after a relation is added
1511 # Called after a relation is added
1513 def relation_added(relation)
1512 def relation_added(relation)
1514 if current_journal
1513 if current_journal
1515 current_journal.journalize_relation(relation, :added)
1514 current_journal.journalize_relation(relation, :added)
1516 current_journal.save
1515 current_journal.save
1517 end
1516 end
1518 end
1517 end
1519
1518
1520 # Called after a relation is removed
1519 # Called after a relation is removed
1521 def relation_removed(relation)
1520 def relation_removed(relation)
1522 if current_journal
1521 if current_journal
1523 current_journal.journalize_relation(relation, :removed)
1522 current_journal.journalize_relation(relation, :removed)
1524 current_journal.save
1523 current_journal.save
1525 end
1524 end
1526 end
1525 end
1527
1526
1528 # Default assignment based on category
1527 # Default assignment based on category
1529 def default_assign
1528 def default_assign
1530 if assigned_to.nil? && category && category.assigned_to
1529 if assigned_to.nil? && category && category.assigned_to
1531 self.assigned_to = category.assigned_to
1530 self.assigned_to = category.assigned_to
1532 end
1531 end
1533 end
1532 end
1534
1533
1535 # Updates start/due dates of following issues
1534 # Updates start/due dates of following issues
1536 def reschedule_following_issues
1535 def reschedule_following_issues
1537 if start_date_changed? || due_date_changed?
1536 if start_date_changed? || due_date_changed?
1538 relations_from.each do |relation|
1537 relations_from.each do |relation|
1539 relation.set_issue_to_dates
1538 relation.set_issue_to_dates
1540 end
1539 end
1541 end
1540 end
1542 end
1541 end
1543
1542
1544 # Closes duplicates if the issue is being closed
1543 # Closes duplicates if the issue is being closed
1545 def close_duplicates
1544 def close_duplicates
1546 if closing?
1545 if closing?
1547 duplicates.each do |duplicate|
1546 duplicates.each do |duplicate|
1548 # Reload is needed in case the duplicate was updated by a previous duplicate
1547 # Reload is needed in case the duplicate was updated by a previous duplicate
1549 duplicate.reload
1548 duplicate.reload
1550 # Don't re-close it if it's already closed
1549 # Don't re-close it if it's already closed
1551 next if duplicate.closed?
1550 next if duplicate.closed?
1552 # Same user and notes
1551 # Same user and notes
1553 if @current_journal
1552 if @current_journal
1554 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1553 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1555 end
1554 end
1556 duplicate.update_attribute :status, self.status
1555 duplicate.update_attribute :status, self.status
1557 end
1556 end
1558 end
1557 end
1559 end
1558 end
1560
1559
1561 # Make sure updated_on is updated when adding a note and set updated_on now
1560 # Make sure updated_on is updated when adding a note and set updated_on now
1562 # so we can set closed_on with the same value on closing
1561 # so we can set closed_on with the same value on closing
1563 def force_updated_on_change
1562 def force_updated_on_change
1564 if @current_journal || changed?
1563 if @current_journal || changed?
1565 self.updated_on = current_time_from_proper_timezone
1564 self.updated_on = current_time_from_proper_timezone
1566 if new_record?
1565 if new_record?
1567 self.created_on = updated_on
1566 self.created_on = updated_on
1568 end
1567 end
1569 end
1568 end
1570 end
1569 end
1571
1570
1572 # Callback for setting closed_on when the issue is closed.
1571 # Callback for setting closed_on when the issue is closed.
1573 # The closed_on attribute stores the time of the last closing
1572 # The closed_on attribute stores the time of the last closing
1574 # and is preserved when the issue is reopened.
1573 # and is preserved when the issue is reopened.
1575 def update_closed_on
1574 def update_closed_on
1576 if closing?
1575 if closing?
1577 self.closed_on = updated_on
1576 self.closed_on = updated_on
1578 end
1577 end
1579 end
1578 end
1580
1579
1581 # Saves the changes in a Journal
1580 # Saves the changes in a Journal
1582 # Called after_save
1581 # Called after_save
1583 def create_journal
1582 def create_journal
1584 if current_journal
1583 if current_journal
1585 current_journal.save
1584 current_journal.save
1586 end
1585 end
1587 end
1586 end
1588
1587
1589 def send_notification
1588 def send_notification
1590 if Setting.notified_events.include?('issue_added')
1589 if Setting.notified_events.include?('issue_added')
1591 Mailer.deliver_issue_add(self)
1590 Mailer.deliver_issue_add(self)
1592 end
1591 end
1593 end
1592 end
1594
1593
1595 # Stores the previous assignee so we can still have access
1594 # Stores the previous assignee so we can still have access
1596 # to it during after_save callbacks (assigned_to_id_was is reset)
1595 # to it during after_save callbacks (assigned_to_id_was is reset)
1597 def set_assigned_to_was
1596 def set_assigned_to_was
1598 @previous_assigned_to_id = assigned_to_id_was
1597 @previous_assigned_to_id = assigned_to_id_was
1599 end
1598 end
1600
1599
1601 # Clears the previous assignee at the end of after_save callbacks
1600 # Clears the previous assignee at the end of after_save callbacks
1602 def clear_assigned_to_was
1601 def clear_assigned_to_was
1603 @assigned_to_was = nil
1602 @assigned_to_was = nil
1604 @previous_assigned_to_id = nil
1603 @previous_assigned_to_id = nil
1605 end
1604 end
1606 end
1605 end
@@ -1,117 +1,117
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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'
21 belongs_to :author, :class_name => 'User'
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'
24 belongs_to :last_reply, :class_name => 'Message'
25 attr_protected :id
25 attr_protected :id
26
26
27 acts_as_searchable :columns => ['subject', 'content'],
27 acts_as_searchable :columns => ['subject', 'content'],
28 :scope => preload(:board => :project),
28 :preload => {:board => :project},
29 :project_key => "#{Board.table_name}.project_id",
29 :project_key => "#{Board.table_name}.project_id"
30 :date_column => "#{table_name}.created_on"
30
31 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
31 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
32 :description => :content,
32 :description => :content,
33 :group => :parent,
33 :group => :parent,
34 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
34 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
35 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
35 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
36 {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
36 {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
37
37
38 acts_as_activity_provider :scope => preload({:board => :project}, :author),
38 acts_as_activity_provider :scope => preload({:board => :project}, :author),
39 :author_key => :author_id
39 :author_key => :author_id
40 acts_as_watchable
40 acts_as_watchable
41
41
42 validates_presence_of :board, :subject, :content
42 validates_presence_of :board, :subject, :content
43 validates_length_of :subject, :maximum => 255
43 validates_length_of :subject, :maximum => 255
44 validate :cannot_reply_to_locked_topic, :on => :create
44 validate :cannot_reply_to_locked_topic, :on => :create
45
45
46 after_create :add_author_as_watcher, :reset_counters!
46 after_create :add_author_as_watcher, :reset_counters!
47 after_update :update_messages_board
47 after_update :update_messages_board
48 after_destroy :reset_counters!
48 after_destroy :reset_counters!
49 after_create :send_notification
49 after_create :send_notification
50
50
51 scope :visible, lambda {|*args|
51 scope :visible, lambda {|*args|
52 joins(:board => :project).
52 joins(:board => :project).
53 where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
53 where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
54 }
54 }
55
55
56 safe_attributes 'subject', 'content'
56 safe_attributes 'subject', 'content'
57 safe_attributes 'locked', 'sticky', 'board_id',
57 safe_attributes 'locked', 'sticky', 'board_id',
58 :if => lambda {|message, user|
58 :if => lambda {|message, user|
59 user.allowed_to?(:edit_messages, message.project)
59 user.allowed_to?(:edit_messages, message.project)
60 }
60 }
61
61
62 def visible?(user=User.current)
62 def visible?(user=User.current)
63 !user.nil? && user.allowed_to?(:view_messages, project)
63 !user.nil? && user.allowed_to?(:view_messages, project)
64 end
64 end
65
65
66 def cannot_reply_to_locked_topic
66 def cannot_reply_to_locked_topic
67 # Can not reply to a locked topic
67 # Can not reply to a locked topic
68 errors.add :base, 'Topic is locked' if root.locked? && self != root
68 errors.add :base, 'Topic is locked' if root.locked? && self != root
69 end
69 end
70
70
71 def update_messages_board
71 def update_messages_board
72 if board_id_changed?
72 if board_id_changed?
73 Message.where(["id = ? OR parent_id = ?", root.id, root.id]).update_all({:board_id => board_id})
73 Message.where(["id = ? OR parent_id = ?", root.id, root.id]).update_all({:board_id => board_id})
74 Board.reset_counters!(board_id_was)
74 Board.reset_counters!(board_id_was)
75 Board.reset_counters!(board_id)
75 Board.reset_counters!(board_id)
76 end
76 end
77 end
77 end
78
78
79 def reset_counters!
79 def reset_counters!
80 if parent && parent.id
80 if parent && parent.id
81 Message.where({:id => parent.id}).update_all({:last_reply_id => parent.children.maximum(:id)})
81 Message.where({:id => parent.id}).update_all({:last_reply_id => parent.children.maximum(:id)})
82 end
82 end
83 board.reset_counters!
83 board.reset_counters!
84 end
84 end
85
85
86 def sticky=(arg)
86 def sticky=(arg)
87 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
87 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
88 end
88 end
89
89
90 def sticky?
90 def sticky?
91 sticky == 1
91 sticky == 1
92 end
92 end
93
93
94 def project
94 def project
95 board.project
95 board.project
96 end
96 end
97
97
98 def editable_by?(usr)
98 def editable_by?(usr)
99 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
99 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
100 end
100 end
101
101
102 def destroyable_by?(usr)
102 def destroyable_by?(usr)
103 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
103 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
104 end
104 end
105
105
106 private
106 private
107
107
108 def add_author_as_watcher
108 def add_author_as_watcher
109 Watcher.create(:watchable => self.root, :user => author)
109 Watcher.create(:watchable => self.root, :user => author)
110 end
110 end
111
111
112 def send_notification
112 def send_notification
113 if Setting.notified_events.include?('message_posted')
113 if Setting.notified_events.include?('message_posted')
114 Mailer.message_posted(self).deliver
114 Mailer.message_posted(self).deliver
115 end
115 end
116 end
116 end
117 end
117 end
@@ -1,89 +1,89
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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'
21 belongs_to :author, :class_name => 'User'
22 has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all
22 has_many :comments, lambda {order("created_on")}, :as => :commented, :dependent => :delete_all
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 attr_protected :id
27 attr_protected :id
28
28
29 acts_as_attachable :edit_permission => :manage_news,
29 acts_as_attachable :edit_permission => :manage_news,
30 :delete_permission => :manage_news
30 :delete_permission => :manage_news
31 acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"],
31 acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"],
32 :scope => preload(:project)
32 :preload => :project
33 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
33 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
34 acts_as_activity_provider :scope => preload(:project, :author),
34 acts_as_activity_provider :scope => preload(:project, :author),
35 :author_key => :author_id
35 :author_key => :author_id
36 acts_as_watchable
36 acts_as_watchable
37
37
38 after_create :add_author_as_watcher
38 after_create :add_author_as_watcher
39 after_create :send_notification
39 after_create :send_notification
40
40
41 scope :visible, lambda {|*args|
41 scope :visible, lambda {|*args|
42 joins(:project).
42 joins(:project).
43 where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args))
43 where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args))
44 }
44 }
45
45
46 safe_attributes 'title', 'summary', 'description'
46 safe_attributes 'title', 'summary', 'description'
47
47
48 def visible?(user=User.current)
48 def visible?(user=User.current)
49 !user.nil? && user.allowed_to?(:view_news, project)
49 !user.nil? && user.allowed_to?(:view_news, project)
50 end
50 end
51
51
52 # Returns true if the news can be commented by user
52 # Returns true if the news can be commented by user
53 def commentable?(user=User.current)
53 def commentable?(user=User.current)
54 user.allowed_to?(:comment_news, project)
54 user.allowed_to?(:comment_news, project)
55 end
55 end
56
56
57 def recipients
57 def recipients
58 project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)}.map(&:mail)
58 project.users.select {|user| user.notify_about?(self) && user.allowed_to?(:view_news, project)}.map(&:mail)
59 end
59 end
60
60
61 # Returns the email addresses that should be cc'd when a new news is added
61 # Returns the email addresses that should be cc'd when a new news is added
62 def cc_for_added_news
62 def cc_for_added_news
63 cc = []
63 cc = []
64 if m = project.enabled_module('news')
64 if m = project.enabled_module('news')
65 cc = m.notified_watchers
65 cc = m.notified_watchers
66 unless project.is_public?
66 unless project.is_public?
67 cc = cc.select {|user| project.users.include?(user)}
67 cc = cc.select {|user| project.users.include?(user)}
68 end
68 end
69 end
69 end
70 cc.map(&:mail)
70 cc.map(&:mail)
71 end
71 end
72
72
73 # returns latest news for projects visible by user
73 # returns latest news for projects visible by user
74 def self.latest(user = User.current, count = 5)
74 def self.latest(user = User.current, count = 5)
75 visible(user).preload(:author, :project).order("#{News.table_name}.created_on DESC").limit(count).to_a
75 visible(user).preload(:author, :project).order("#{News.table_name}.created_on DESC").limit(count).to_a
76 end
76 end
77
77
78 private
78 private
79
79
80 def add_author_as_watcher
80 def add_author_as_watcher
81 Watcher.create(:watchable => self, :user => author)
81 Watcher.create(:watchable => self, :user => author)
82 end
82 end
83
83
84 def send_notification
84 def send_notification
85 if Setting.notified_events.include?('news_added')
85 if Setting.notified_events.include?('news_added')
86 Mailer.news_added(self).deliver
86 Mailer.news_added(self).deliver
87 end
87 end
88 end
88 end
89 end
89 end
@@ -1,298 +1,299
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 :scope => preload(:wiki => :project).joins(:content, {:wiki => :project}),
36 :scope => joins(:content, {:wiki => :project}),
37 :preload => {:wiki => :project},
37 :permission => :view_wiki_pages,
38 :permission => :view_wiki_pages,
38 :project_key => "#{Wiki.table_name}.project_id"
39 :project_key => "#{Wiki.table_name}.project_id"
39
40
40 attr_accessor :redirect_existing_links
41 attr_accessor :redirect_existing_links
41
42
42 validates_presence_of :title
43 validates_presence_of :title
43 validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
44 validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
44 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
45 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
45 validates_associated :content
46 validates_associated :content
46 attr_protected :id
47 attr_protected :id
47
48
48 validate :validate_parent_title
49 validate :validate_parent_title
49 before_destroy :delete_redirects
50 before_destroy :delete_redirects
50 before_save :handle_rename_or_move
51 before_save :handle_rename_or_move
51 after_save :handle_children_move
52 after_save :handle_children_move
52
53
53 # eager load information about last updates, without loading text
54 # eager load information about last updates, without loading text
54 scope :with_updated_on, lambda {
55 scope :with_updated_on, lambda {
55 select("#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version").
56 select("#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version").
56 joins("LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id")
57 joins("LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id")
57 }
58 }
58
59
59 # Wiki pages that are protected by default
60 # Wiki pages that are protected by default
60 DEFAULT_PROTECTED_PAGES = %w(sidebar)
61 DEFAULT_PROTECTED_PAGES = %w(sidebar)
61
62
62 safe_attributes 'parent_id', 'parent_title', 'title', 'redirect_existing_links', 'wiki_id',
63 safe_attributes 'parent_id', 'parent_title', 'title', 'redirect_existing_links', 'wiki_id',
63 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
64 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
64
65
65 def initialize(attributes=nil, *args)
66 def initialize(attributes=nil, *args)
66 super
67 super
67 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
68 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
68 self.protected = true
69 self.protected = true
69 end
70 end
70 end
71 end
71
72
72 def visible?(user=User.current)
73 def visible?(user=User.current)
73 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
74 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
74 end
75 end
75
76
76 def title=(value)
77 def title=(value)
77 value = Wiki.titleize(value)
78 value = Wiki.titleize(value)
78 write_attribute(:title, value)
79 write_attribute(:title, value)
79 end
80 end
80
81
81 def safe_attributes=(attrs, user=User.current)
82 def safe_attributes=(attrs, user=User.current)
82 return unless attrs.is_a?(Hash)
83 return unless attrs.is_a?(Hash)
83 attrs = attrs.deep_dup
84 attrs = attrs.deep_dup
84
85
85 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
86 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
86 if (w_id = attrs.delete('wiki_id')) && safe_attribute?('wiki_id')
87 if (w_id = attrs.delete('wiki_id')) && safe_attribute?('wiki_id')
87 if (w = Wiki.find_by_id(w_id)) && w.project && user.allowed_to?(:rename_wiki_pages, w.project)
88 if (w = Wiki.find_by_id(w_id)) && w.project && user.allowed_to?(:rename_wiki_pages, w.project)
88 self.wiki = w
89 self.wiki = w
89 end
90 end
90 end
91 end
91
92
92 super attrs, user
93 super attrs, user
93 end
94 end
94
95
95 # Manages redirects if page is renamed or moved
96 # Manages redirects if page is renamed or moved
96 def handle_rename_or_move
97 def handle_rename_or_move
97 if !new_record? && (title_changed? || wiki_id_changed?)
98 if !new_record? && (title_changed? || wiki_id_changed?)
98 # Update redirects that point to the old title
99 # Update redirects that point to the old title
99 WikiRedirect.where(:redirects_to => title_was, :redirects_to_wiki_id => wiki_id_was).each do |r|
100 WikiRedirect.where(:redirects_to => title_was, :redirects_to_wiki_id => wiki_id_was).each do |r|
100 r.redirects_to = title
101 r.redirects_to = title
101 r.redirects_to_wiki_id = wiki_id
102 r.redirects_to_wiki_id = wiki_id
102 (r.title == r.redirects_to && r.wiki_id == r.redirects_to_wiki_id) ? r.destroy : r.save
103 (r.title == r.redirects_to && r.wiki_id == r.redirects_to_wiki_id) ? r.destroy : r.save
103 end
104 end
104 # Remove redirects for the new title
105 # Remove redirects for the new title
105 WikiRedirect.where(:wiki_id => wiki_id, :title => title).delete_all
106 WikiRedirect.where(:wiki_id => wiki_id, :title => title).delete_all
106 # Create a redirect to the new title
107 # Create a redirect to the new title
107 unless redirect_existing_links == "0"
108 unless redirect_existing_links == "0"
108 WikiRedirect.create(
109 WikiRedirect.create(
109 :wiki_id => wiki_id_was, :title => title_was,
110 :wiki_id => wiki_id_was, :title => title_was,
110 :redirects_to_wiki_id => wiki_id, :redirects_to => title
111 :redirects_to_wiki_id => wiki_id, :redirects_to => title
111 )
112 )
112 end
113 end
113 end
114 end
114 if !new_record? && wiki_id_changed? && parent.present?
115 if !new_record? && wiki_id_changed? && parent.present?
115 unless parent.wiki_id == wiki_id
116 unless parent.wiki_id == wiki_id
116 self.parent_id = nil
117 self.parent_id = nil
117 end
118 end
118 end
119 end
119 end
120 end
120 private :handle_rename_or_move
121 private :handle_rename_or_move
121
122
122 # Moves child pages if page was moved
123 # Moves child pages if page was moved
123 def handle_children_move
124 def handle_children_move
124 if !new_record? && wiki_id_changed?
125 if !new_record? && wiki_id_changed?
125 children.each do |child|
126 children.each do |child|
126 child.wiki_id = wiki_id
127 child.wiki_id = wiki_id
127 child.redirect_existing_links = redirect_existing_links
128 child.redirect_existing_links = redirect_existing_links
128 unless child.save
129 unless child.save
129 WikiPage.where(:id => child.id).update_all :parent_nil => nil
130 WikiPage.where(:id => child.id).update_all :parent_nil => nil
130 end
131 end
131 end
132 end
132 end
133 end
133 end
134 end
134 private :handle_children_move
135 private :handle_children_move
135
136
136 # Deletes redirects to this page
137 # Deletes redirects to this page
137 def delete_redirects
138 def delete_redirects
138 WikiRedirect.where(:redirects_to_wiki_id => wiki_id, :redirects_to => title).delete_all
139 WikiRedirect.where(:redirects_to_wiki_id => wiki_id, :redirects_to => title).delete_all
139 end
140 end
140
141
141 def pretty_title
142 def pretty_title
142 WikiPage.pretty_title(title)
143 WikiPage.pretty_title(title)
143 end
144 end
144
145
145 def content_for_version(version=nil)
146 def content_for_version(version=nil)
146 if content
147 if content
147 result = content.versions.find_by_version(version.to_i) if version
148 result = content.versions.find_by_version(version.to_i) if version
148 result ||= content
149 result ||= content
149 result
150 result
150 end
151 end
151 end
152 end
152
153
153 def diff(version_to=nil, version_from=nil)
154 def diff(version_to=nil, version_from=nil)
154 version_to = version_to ? version_to.to_i : self.content.version
155 version_to = version_to ? version_to.to_i : self.content.version
155 content_to = content.versions.find_by_version(version_to)
156 content_to = content.versions.find_by_version(version_to)
156 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
157 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
157 return nil unless content_to && content_from
158 return nil unless content_to && content_from
158
159
159 if content_from.version > content_to.version
160 if content_from.version > content_to.version
160 content_to, content_from = content_from, content_to
161 content_to, content_from = content_from, content_to
161 end
162 end
162
163
163 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
164 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
164 end
165 end
165
166
166 def annotate(version=nil)
167 def annotate(version=nil)
167 version = version ? version.to_i : self.content.version
168 version = version ? version.to_i : self.content.version
168 c = content.versions.find_by_version(version)
169 c = content.versions.find_by_version(version)
169 c ? WikiAnnotate.new(c) : nil
170 c ? WikiAnnotate.new(c) : nil
170 end
171 end
171
172
172 def self.pretty_title(str)
173 def self.pretty_title(str)
173 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
174 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
174 end
175 end
175
176
176 def project
177 def project
177 wiki.try(:project)
178 wiki.try(:project)
178 end
179 end
179
180
180 def text
181 def text
181 content.text if content
182 content.text if content
182 end
183 end
183
184
184 def updated_on
185 def updated_on
185 unless @updated_on
186 unless @updated_on
186 if time = read_attribute(:updated_on)
187 if time = read_attribute(:updated_on)
187 # content updated_on was eager loaded with the page
188 # content updated_on was eager loaded with the page
188 begin
189 begin
189 @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
190 @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
190 rescue
191 rescue
191 end
192 end
192 else
193 else
193 @updated_on = content && content.updated_on
194 @updated_on = content && content.updated_on
194 end
195 end
195 end
196 end
196 @updated_on
197 @updated_on
197 end
198 end
198
199
199 # Returns true if usr is allowed to edit the page, otherwise false
200 # Returns true if usr is allowed to edit the page, otherwise false
200 def editable_by?(usr)
201 def editable_by?(usr)
201 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
202 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
202 end
203 end
203
204
204 def attachments_deletable?(usr=User.current)
205 def attachments_deletable?(usr=User.current)
205 editable_by?(usr) && super(usr)
206 editable_by?(usr) && super(usr)
206 end
207 end
207
208
208 def parent_title
209 def parent_title
209 @parent_title || (self.parent && self.parent.pretty_title)
210 @parent_title || (self.parent && self.parent.pretty_title)
210 end
211 end
211
212
212 def parent_title=(t)
213 def parent_title=(t)
213 @parent_title = t
214 @parent_title = t
214 parent_page = t.blank? ? nil : self.wiki.find_page(t)
215 parent_page = t.blank? ? nil : self.wiki.find_page(t)
215 self.parent = parent_page
216 self.parent = parent_page
216 end
217 end
217
218
218 # Saves the page and its content if text was changed
219 # Saves the page and its content if text was changed
219 # Return true if the page was saved
220 # Return true if the page was saved
220 def save_with_content(content)
221 def save_with_content(content)
221 ret = nil
222 ret = nil
222 transaction do
223 transaction do
223 ret = save
224 ret = save
224 if content.text_changed?
225 if content.text_changed?
225 begin
226 begin
226 self.content = content
227 self.content = content
227 ret = ret && content.changed?
228 ret = ret && content.changed?
228 rescue ActiveRecord::RecordNotSaved
229 rescue ActiveRecord::RecordNotSaved
229 ret = false
230 ret = false
230 end
231 end
231 end
232 end
232 raise ActiveRecord::Rollback unless ret
233 raise ActiveRecord::Rollback unless ret
233 end
234 end
234 ret
235 ret
235 end
236 end
236
237
237 protected
238 protected
238
239
239 def validate_parent_title
240 def validate_parent_title
240 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
241 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
241 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
242 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
242 if parent_id_changed? && parent && (parent.wiki_id != wiki_id)
243 if parent_id_changed? && parent && (parent.wiki_id != wiki_id)
243 errors.add(:parent_title, :not_same_project)
244 errors.add(:parent_title, :not_same_project)
244 end
245 end
245 end
246 end
246 end
247 end
247
248
248 class WikiDiff < Redmine::Helpers::Diff
249 class WikiDiff < Redmine::Helpers::Diff
249 attr_reader :content_to, :content_from
250 attr_reader :content_to, :content_from
250
251
251 def initialize(content_to, content_from)
252 def initialize(content_to, content_from)
252 @content_to = content_to
253 @content_to = content_to
253 @content_from = content_from
254 @content_from = content_from
254 super(content_to.text, content_from.text)
255 super(content_to.text, content_from.text)
255 end
256 end
256 end
257 end
257
258
258 class WikiAnnotate
259 class WikiAnnotate
259 attr_reader :lines, :content
260 attr_reader :lines, :content
260
261
261 def initialize(content)
262 def initialize(content)
262 @content = content
263 @content = content
263 current = content
264 current = content
264 current_lines = current.text.split(/\r?\n/)
265 current_lines = current.text.split(/\r?\n/)
265 @lines = current_lines.collect {|t| [nil, nil, t]}
266 @lines = current_lines.collect {|t| [nil, nil, t]}
266 positions = []
267 positions = []
267 current_lines.size.times {|i| positions << i}
268 current_lines.size.times {|i| positions << i}
268 while (current.previous)
269 while (current.previous)
269 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
270 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
270 d.each_slice(3) do |s|
271 d.each_slice(3) do |s|
271 sign, line = s[0], s[1]
272 sign, line = s[0], s[1]
272 if sign == '+' && positions[line] && positions[line] != -1
273 if sign == '+' && positions[line] && positions[line] != -1
273 if @lines[positions[line]][0].nil?
274 if @lines[positions[line]][0].nil?
274 @lines[positions[line]][0] = current.version
275 @lines[positions[line]][0] = current.version
275 @lines[positions[line]][1] = current.author
276 @lines[positions[line]][1] = current.author
276 end
277 end
277 end
278 end
278 end
279 end
279 d.each_slice(3) do |s|
280 d.each_slice(3) do |s|
280 sign, line = s[0], s[1]
281 sign, line = s[0], s[1]
281 if sign == '-'
282 if sign == '-'
282 positions.insert(line, -1)
283 positions.insert(line, -1)
283 else
284 else
284 positions[line] = nil
285 positions[line] = nil
285 end
286 end
286 end
287 end
287 positions.compact!
288 positions.compact!
288 # Stop if every line is annotated
289 # Stop if every line is annotated
289 break unless @lines.detect { |line| line[0].nil? }
290 break unless @lines.detect { |line| line[0].nil? }
290 current = current.previous
291 current = current.previous
291 end
292 end
292 @lines.each { |line|
293 @lines.each { |line|
293 line[0] ||= current.version
294 line[0] ||= current.version
294 # if the last known version is > 1 (eg. history was cleared), we don't know the author
295 # if the last known version is > 1 (eg. history was cleared), we don't know the author
295 line[1] ||= current.author if current.version == 1
296 line[1] ||= current.author if current.version == 1
296 }
297 }
297 end
298 end
298 end
299 end
@@ -1,65 +1,56
1 <h2><%= l(:label_search) %></h2>
1 <h2><%= l(:label_search) %></h2>
2
2
3 <div class="box">
3 <div class="box">
4 <%= form_tag({}, :method => :get, :id => 'search-form') do %>
4 <%= form_tag({}, :method => :get, :id => 'search-form') do %>
5 <%= label_tag "search-input", l(:description_search), :class => "hidden-for-sighted" %>
5 <%= label_tag "search-input", l(:description_search), :class => "hidden-for-sighted" %>
6 <p><%= text_field_tag 'q', @question, :size => 60, :id => 'search-input' %>
6 <p><%= text_field_tag 'q', @question, :size => 60, :id => 'search-input' %>
7 <%= project_select_tag %>
7 <%= project_select_tag %>
8 <%= hidden_field_tag 'all_words', '', :id => nil %>
8 <%= hidden_field_tag 'all_words', '', :id => nil %>
9 <label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>
9 <label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>
10 <%= hidden_field_tag 'titles_only', '', :id => nil %>
10 <%= hidden_field_tag 'titles_only', '', :id => nil %>
11 <label><%= check_box_tag 'titles_only', 1, @titles_only %> <%= l(:label_search_titles_only) %></label>
11 <label><%= check_box_tag 'titles_only', 1, @titles_only %> <%= l(:label_search_titles_only) %></label>
12 </p>
12 </p>
13
13
14 <p id="search-types">
14 <p id="search-types">
15 <% @object_types.each do |t| %>
15 <% @object_types.each do |t| %>
16 <label><%= check_box_tag t, 1, @scope.include?(t) %> <%= link_to type_label(t), "#" %></label>
16 <label><%= check_box_tag t, 1, @scope.include?(t) %> <%= link_to type_label(t), "#" %></label>
17 <% end %>
17 <% end %>
18 </p>
18 </p>
19
19
20 <p><%= submit_tag l(:button_submit) %></p>
20 <p><%= submit_tag l(:button_submit) %></p>
21 <% end %>
21 <% end %>
22 </div>
22 </div>
23
23
24 <% if @results %>
24 <% if @results %>
25 <div id="search-results-counts">
25 <div id="search-results-counts">
26 <%= render_results_by_type(@results_by_type) unless @scope.size == 1 %>
26 <%= render_results_by_type(@result_count_by_type) unless @scope.size == 1 %>
27 </div>
27 </div>
28 <h3><%= l(:label_result_plural) %> (<%= @results_by_type.values.sum %>)</h3>
28 <h3><%= l(:label_result_plural) %> (<%= @result_count %>)</h3>
29 <dl id="search-results">
29 <dl id="search-results">
30 <% @results.each do |e| %>
30 <% @results.each do |e| %>
31 <dt class="<%= e.event_type %>">
31 <dt class="<%= e.event_type %>">
32 <%= content_tag('span', h(e.project), :class => 'project') unless @project == e.project %>
32 <%= content_tag('span', h(e.project), :class => 'project') unless @project == e.project %>
33 <%= link_to(highlight_tokens(e.event_title.truncate(255), @tokens), e.event_url) %>
33 <%= link_to(highlight_tokens(e.event_title.truncate(255), @tokens), e.event_url) %>
34 </dt>
34 </dt>
35 <dd><span class="description"><%= highlight_tokens(e.event_description, @tokens) %></span>
35 <dd><span class="description"><%= highlight_tokens(e.event_description, @tokens) %></span>
36 <span class="author"><%= format_time(e.event_datetime) %></span></dd>
36 <span class="author"><%= format_time(e.event_datetime) %></span></dd>
37 <% end %>
37 <% end %>
38 </dl>
38 </dl>
39 <% end %>
39 <% end %>
40
40
41 <p class="pagination">
41 <% if @result_pages %>
42 <% if @pagination_previous_date %>
42 <p class="pagination"><%= pagination_links_full @result_pages, @result_count, :per_page_links => false %></p>
43 <%= link_to_content_update("\xc2\xab " + l(:label_previous),
44 params.merge(:previous => 1,
45 :offset => @pagination_previous_date.strftime("%Y%m%d%H%M%S"))) %>&nbsp;
46 <% end %>
43 <% end %>
47 <% if @pagination_next_date %>
48 <%= link_to_content_update(l(:label_next) + " \xc2\xbb",
49 params.merge(:previous => nil,
50 :offset => @pagination_next_date.strftime("%Y%m%d%H%M%S"))) %>
51 <% end %>
52 </p>
53
44
54 <% html_title(l(:label_search)) -%>
45 <% html_title(l(:label_search)) -%>
55
46
56 <%= javascript_tag do %>
47 <%= javascript_tag do %>
57 $("#search-types a").click(function(e){
48 $("#search-types a").click(function(e){
58 e.preventDefault();
49 e.preventDefault();
59 $("#search-types input[type=checkbox]").prop('checked', false);
50 $("#search-types input[type=checkbox]").prop('checked', false);
60 $(this).siblings("input[type=checkbox]").prop('checked', true);
51 $(this).siblings("input[type=checkbox]").prop('checked', true);
61 if ($("#search-input").val() != "") {
52 if ($("#search-input").val() != "") {
62 $("#search-form").submit();
53 $("#search-form").submit();
63 }
54 }
64 });
55 });
65 <% end %>
56 <% end %>
@@ -1,145 +1,153
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 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 module Redmine
18 module Redmine
19 module Acts
19 module Acts
20 module Searchable
20 module Searchable
21 def self.included(base)
21 def self.included(base)
22 base.extend ClassMethods
22 base.extend ClassMethods
23 end
23 end
24
24
25 module ClassMethods
25 module ClassMethods
26 # Adds the search methods to the class.
27 #
26 # Options:
28 # Options:
27 # * :columns - a column or an array of columns to search
29 # * :columns - a column or an array of columns to search
28 # * :project_key - project foreign key (default to project_id)
30 # * :project_key - project foreign key (default to project_id)
29 # * :date_column - name of the datetime column (default to created_on)
31 # * :date_column - name of the datetime column used to sort results (default to :created_on)
30 # * :sort_order - name of the column used to sort results (default to :date_column or created_on)
32 # * :permission - permission required to search the model
31 # * :permission - permission required to search the model (default to :view_"objects")
33 # * :scope - scope used to search results
34 # * :preload - associations to preload when loading results for display
32 def acts_as_searchable(options = {})
35 def acts_as_searchable(options = {})
33 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
36 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
34 options.assert_valid_keys(:columns, :project_key, :date_column, :order_column, :search_custom_fields, :permission, :scope)
37 options.assert_valid_keys(:columns, :project_key, :date_column, :permission, :scope, :preload)
35
38
36 cattr_accessor :searchable_options
39 cattr_accessor :searchable_options
37 self.searchable_options = options
40 self.searchable_options = options
38
41
39 if searchable_options[:columns].nil?
42 if searchable_options[:columns].nil?
40 raise 'No searchable column defined.'
43 raise 'No searchable column defined.'
41 elsif !searchable_options[:columns].is_a?(Array)
44 elsif !searchable_options[:columns].is_a?(Array)
42 searchable_options[:columns] = [] << searchable_options[:columns]
45 searchable_options[:columns] = [] << searchable_options[:columns]
43 end
46 end
44
47
45 searchable_options[:project_key] ||= "#{table_name}.project_id"
48 searchable_options[:project_key] ||= "#{table_name}.project_id"
46 searchable_options[:date_column] ||= "#{table_name}.created_on"
49 searchable_options[:date_column] ||= :created_on
47 searchable_options[:order_column] ||= searchable_options[:date_column]
48
50
49 # Should we search custom fields on this model ?
51 # Should we search custom fields on this model ?
50 searchable_options[:search_custom_fields] = !reflect_on_association(:custom_values).nil?
52 searchable_options[:search_custom_fields] = !reflect_on_association(:custom_values).nil?
51
53
52 send :include, Redmine::Acts::Searchable::InstanceMethods
54 send :include, Redmine::Acts::Searchable::InstanceMethods
53 end
55 end
54 end
56 end
55
57
56 module InstanceMethods
58 module InstanceMethods
57 def self.included(base)
59 def self.included(base)
58 base.extend ClassMethods
60 base.extend ClassMethods
59 end
61 end
60
62
61 module ClassMethods
63 module ClassMethods
62 # Searches the model for the given tokens
64 # Searches the model for the given tokens and user visibility.
63 # projects argument can be either nil (will search all projects), a project or an array of projects
65 # The projects argument can be either nil (will search all projects), a project or an array of projects.
64 # Returns the results and the results count
66 # Returns an array that contains the rank and id of all results.
65 def search(tokens, projects=nil, options={})
67 # In current implementation, the rank is the record timestamp.
68 #
69 # Valid options:
70 # * :titles_only - searches tokens in the first searchable column only
71 # * :all_words - searches results that match all token
72 # * :limit - maximum number of results to return
73 #
74 # Example:
75 # Issue.search_result_ranks_and_ids("foo")
76 # # => [[Tue, 26 Jun 2007 22:16:00 UTC +00:00, 69], [Mon, 08 Oct 2007 14:31:00 UTC +00:00, 123]]
77 def search_result_ranks_and_ids(tokens, user=User.current, projects=nil, options={})
66 if projects.is_a?(Array) && projects.empty?
78 if projects.is_a?(Array) && projects.empty?
67 # no results
79 # no results
68 return [[], 0]
80 return []
69 end
81 end
70
82
71 # TODO: make user an argument
72 user = User.current
73 tokens = [] << tokens unless tokens.is_a?(Array)
83 tokens = [] << tokens unless tokens.is_a?(Array)
74 projects = [] << projects if projects.is_a?(Project)
84 projects = [] << projects if projects.is_a?(Project)
75
85
76 limit_options = {}
77 limit_options[:limit] = options[:limit] if options[:limit]
78
79 columns = searchable_options[:columns]
86 columns = searchable_options[:columns]
80 columns = columns[0..0] if options[:titles_only]
87 columns = columns[0..0] if options[:titles_only]
81
88
82 token_clauses = columns.collect {|column| "(LOWER(#{column}) LIKE ?)"}
89 token_clauses = columns.collect {|column| "(LOWER(#{column}) LIKE ?)"}
83
90
84 if !options[:titles_only] && searchable_options[:search_custom_fields]
91 if !options[:titles_only] && searchable_options[:search_custom_fields]
85 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true)
92 searchable_custom_fields = CustomField.where(:type => "#{self.name}CustomField", :searchable => true)
86 fields_by_visibility = searchable_custom_fields.group_by {|field|
93 fields_by_visibility = searchable_custom_fields.group_by {|field|
87 field.visibility_by_project_condition(searchable_options[:project_key], user, "cfs.custom_field_id")
94 field.visibility_by_project_condition(searchable_options[:project_key], user, "cfs.custom_field_id")
88 }
95 }
89 # only 1 subquery for all custom fields with the same visibility statement
96 # only 1 subquery for all custom fields with the same visibility statement
90 fields_by_visibility.each do |visibility, fields|
97 fields_by_visibility.each do |visibility, fields|
91 ids = fields.map(&:id).join(',')
98 ids = fields.map(&:id).join(',')
92 sql = "#{table_name}.id IN (SELECT cfs.customized_id FROM #{CustomValue.table_name} cfs" +
99 sql = "#{table_name}.id IN (SELECT cfs.customized_id FROM #{CustomValue.table_name} cfs" +
93 " WHERE cfs.customized_type='#{self.name}' AND cfs.customized_id=#{table_name}.id AND LOWER(cfs.value) LIKE ?" +
100 " WHERE cfs.customized_type='#{self.name}' AND cfs.customized_id=#{table_name}.id AND LOWER(cfs.value) LIKE ?" +
94 " AND cfs.custom_field_id IN (#{ids})" +
101 " AND cfs.custom_field_id IN (#{ids})" +
95 " AND #{visibility})"
102 " AND #{visibility})"
96 token_clauses << sql
103 token_clauses << sql
97 end
104 end
98 end
105 end
99
106
100 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
107 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
101
108
102 tokens_conditions = [sql, * (tokens.collect {|w| "%#{w.downcase}%"} * token_clauses.size).sort]
109 tokens_conditions = [sql, * (tokens.collect {|w| "%#{w.downcase}%"} * token_clauses.size).sort]
103
110
104 scope = (searchable_options[:scope] || self)
111 scope = (searchable_options[:scope] || self)
105 if scope.is_a? Proc
112 if scope.is_a? Proc
106 scope = scope.call
113 scope = scope.call
107 end
114 end
108 project_conditions = []
115
109 if searchable_options.has_key?(:permission)
116 if respond_to?(:visible) && !searchable_options.has_key?(:permission)
110 project_conditions << Project.allowed_to_condition(user, searchable_options[:permission] || :view_project)
111 elsif respond_to?(:visible)
112 scope = scope.visible(user)
117 scope = scope.visible(user)
113 else
118 else
114 ActiveSupport::Deprecation.warn "acts_as_searchable with implicit :permission option is deprecated. Add a visible scope to the #{self.name} model or use explicit :permission option."
119 permission = searchable_options[:permission] || :view_project
115 project_conditions << Project.allowed_to_condition(user, "view_#{self.name.underscore.pluralize}".to_sym)
120 scope = scope.where(Project.allowed_to_condition(user, permission))
116 end
121 end
122
117 # TODO: use visible scope options instead
123 # TODO: use visible scope options instead
118 project_conditions << "#{searchable_options[:project_key]} IN (#{projects.collect(&:id).join(',')})" unless projects.nil?
124 if projects
119 project_conditions = project_conditions.empty? ? nil : project_conditions.join(' AND ')
125 scope = scope.where("#{searchable_options[:project_key]} IN (?)", projects.map(&:id))
126 end
120
127
121 results = []
128 results = []
122 results_count = 0
129 results_count = 0
123
130
124 scope = scope.
131 scope.
125 joins(searchable_options[:include]).
132 reorder(searchable_options[:date_column] => :desc, :id => :desc).
126 order("#{searchable_options[:order_column]} " + (options[:before] ? 'DESC' : 'ASC')).
127 where(project_conditions).
128 where(tokens_conditions).
133 where(tokens_conditions).
129 uniq
134 limit(options[:limit]).
130
135 uniq.
131 results_count = scope.count
136 pluck(searchable_options[:date_column], :id)
137 end
132
138
133 scope_with_limit = scope.limit(options[:limit])
139 # Returns search results of given ids
134 if options[:offset]
140 def search_results_from_ids(ids)
135 scope_with_limit = scope_with_limit.where("#{searchable_options[:date_column]} #{options[:before] ? '<' : '>'} ?", options[:offset])
141 where(:id => ids).preload(searchable_options[:preload]).to_a
136 end
142 end
137 results = scope_with_limit.to_a
138
143
139 [results, results_count]
144 # Returns search results with same arguments as search_result_ranks_and_ids
145 def search_results(*args)
146 ranks_and_ids = search_result_ranks_and_ids(*args)
147 search_results_from_ids(ranks_and_ids.map(&:last))
140 end
148 end
141 end
149 end
142 end
150 end
143 end
151 end
144 end
152 end
145 end
153 end
@@ -1,288 +1,292
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class SearchControllerTest < ActionController::TestCase
20 class SearchControllerTest < ActionController::TestCase
21 fixtures :projects, :enabled_modules, :roles, :users, :members, :member_roles,
21 fixtures :projects, :enabled_modules, :roles, :users, :members, :member_roles,
22 :issues, :trackers, :issue_statuses, :enumerations,
22 :issues, :trackers, :issue_statuses, :enumerations,
23 :custom_fields, :custom_values,
23 :custom_fields, :custom_values,
24 :custom_fields_projects, :custom_fields_trackers,
24 :custom_fields_projects, :custom_fields_trackers,
25 :repositories, :changesets
25 :repositories, :changesets
26
26
27 def setup
27 def setup
28 User.current = nil
28 User.current = nil
29 end
29 end
30
30
31 def test_search_for_projects
31 def test_search_for_projects
32 get :index
32 get :index
33 assert_response :success
33 assert_response :success
34 assert_template 'index'
34 assert_template 'index'
35
35
36 get :index, :q => "cook"
36 get :index, :q => "cook"
37 assert_response :success
37 assert_response :success
38 assert_template 'index'
38 assert_template 'index'
39 assert assigns(:results).include?(Project.find(1))
39 assert assigns(:results).include?(Project.find(1))
40 end
40 end
41
41
42 def test_search_on_archived_project_should_return_404
42 def test_search_on_archived_project_should_return_404
43 Project.find(3).archive
43 Project.find(3).archive
44 get :index, :id => 3
44 get :index, :id => 3
45 assert_response 404
45 assert_response 404
46 end
46 end
47
47
48 def test_search_on_invisible_project_by_user_should_be_denied
48 def test_search_on_invisible_project_by_user_should_be_denied
49 @request.session[:user_id] = 7
49 @request.session[:user_id] = 7
50 get :index, :id => 2
50 get :index, :id => 2
51 assert_response 403
51 assert_response 403
52 end
52 end
53
53
54 def test_search_on_invisible_project_by_anonymous_user_should_redirect
54 def test_search_on_invisible_project_by_anonymous_user_should_redirect
55 get :index, :id => 2
55 get :index, :id => 2
56 assert_response 302
56 assert_response 302
57 end
57 end
58
58
59 def test_search_on_private_project_by_member_should_succeed
59 def test_search_on_private_project_by_member_should_succeed
60 @request.session[:user_id] = 2
60 @request.session[:user_id] = 2
61 get :index, :id => 2
61 get :index, :id => 2
62 assert_response :success
62 assert_response :success
63 end
63 end
64
64
65 def test_search_all_projects
65 def test_search_all_projects
66 with_settings :default_language => 'en' do
66 with_settings :default_language => 'en' do
67 get :index, :q => 'recipe subproject commit', :all_words => ''
67 get :index, :q => 'recipe subproject commit', :all_words => ''
68 end
68 end
69 assert_response :success
69 assert_response :success
70 assert_template 'index'
70 assert_template 'index'
71
71
72 assert assigns(:results).include?(Issue.find(2))
72 assert assigns(:results).include?(Issue.find(2))
73 assert assigns(:results).include?(Issue.find(5))
73 assert assigns(:results).include?(Issue.find(5))
74 assert assigns(:results).include?(Changeset.find(101))
74 assert assigns(:results).include?(Changeset.find(101))
75 assert_select 'dt.issue a', :text => /Add ingredients categories/
75 assert_select 'dt.issue a', :text => /Add ingredients categories/
76 assert_select 'dd', :text => /should be classified by categories/
76 assert_select 'dd', :text => /should be classified by categories/
77
77
78 assert assigns(:results_by_type).is_a?(Hash)
78 assert assigns(:result_count_by_type).is_a?(Hash)
79 assert_equal 5, assigns(:results_by_type)['changesets']
79 assert_equal 5, assigns(:result_count_by_type)['changesets']
80 assert_select 'a', :text => 'Changesets (5)'
80 assert_select 'a', :text => 'Changesets (5)'
81 end
81 end
82
82
83 def test_search_issues
83 def test_search_issues
84 get :index, :q => 'issue', :issues => 1
84 get :index, :q => 'issue', :issues => 1
85 assert_response :success
85 assert_response :success
86 assert_template 'index'
86 assert_template 'index'
87
87
88 assert_equal true, assigns(:all_words)
88 assert_equal true, assigns(:all_words)
89 assert_equal false, assigns(:titles_only)
89 assert_equal false, assigns(:titles_only)
90 assert assigns(:results).include?(Issue.find(8))
90 assert assigns(:results).include?(Issue.find(8))
91 assert assigns(:results).include?(Issue.find(5))
91 assert assigns(:results).include?(Issue.find(5))
92 assert_select 'dt.issue.closed a', :text => /Closed/
92 assert_select 'dt.issue.closed a', :text => /Closed/
93 end
93 end
94
94
95 def test_search_issues_should_search_notes
95 def test_search_issues_should_search_notes
96 Journal.create!(:journalized => Issue.find(2), :notes => 'Issue notes with searchkeyword')
96 Journal.create!(:journalized => Issue.find(2), :notes => 'Issue notes with searchkeyword')
97
97
98 get :index, :q => 'searchkeyword', :issues => 1
98 get :index, :q => 'searchkeyword', :issues => 1
99 assert_response :success
99 assert_response :success
100 assert_include Issue.find(2), assigns(:results)
100 assert_include Issue.find(2), assigns(:results)
101 end
101 end
102
102
103 def test_search_issues_with_multiple_matches_in_journals_should_return_issue_once
103 def test_search_issues_with_multiple_matches_in_journals_should_return_issue_once
104 Journal.create!(:journalized => Issue.find(2), :notes => 'Issue notes with searchkeyword')
104 Journal.create!(:journalized => Issue.find(2), :notes => 'Issue notes with searchkeyword')
105 Journal.create!(:journalized => Issue.find(2), :notes => 'Issue notes with searchkeyword')
105 Journal.create!(:journalized => Issue.find(2), :notes => 'Issue notes with searchkeyword')
106
106
107 get :index, :q => 'searchkeyword', :issues => 1
107 get :index, :q => 'searchkeyword', :issues => 1
108 assert_response :success
108 assert_response :success
109 assert_include Issue.find(2), assigns(:results)
109 assert_include Issue.find(2), assigns(:results)
110 assert_equal 1, assigns(:results).size
110 assert_equal 1, assigns(:results).size
111 end
111 end
112
112
113 def test_search_issues_should_search_private_notes_with_permission_only
113 def test_search_issues_should_search_private_notes_with_permission_only
114 Journal.create!(:journalized => Issue.find(2), :notes => 'Private notes with searchkeyword', :private_notes => true)
114 Journal.create!(:journalized => Issue.find(2), :notes => 'Private notes with searchkeyword', :private_notes => true)
115 @request.session[:user_id] = 2
115 @request.session[:user_id] = 2
116
116
117 Role.find(1).add_permission! :view_private_notes
117 Role.find(1).add_permission! :view_private_notes
118 get :index, :q => 'searchkeyword', :issues => 1
118 get :index, :q => 'searchkeyword', :issues => 1
119 assert_response :success
119 assert_response :success
120 assert_include Issue.find(2), assigns(:results)
120 assert_include Issue.find(2), assigns(:results)
121
121
122 Role.find(1).remove_permission! :view_private_notes
122 Role.find(1).remove_permission! :view_private_notes
123 get :index, :q => 'searchkeyword', :issues => 1
123 get :index, :q => 'searchkeyword', :issues => 1
124 assert_response :success
124 assert_response :success
125 assert_not_include Issue.find(2), assigns(:results)
125 assert_not_include Issue.find(2), assigns(:results)
126 end
126 end
127
127
128 def test_search_all_projects_with_scope_param
128 def test_search_all_projects_with_scope_param
129 get :index, :q => 'issue', :scope => 'all'
129 get :index, :q => 'issue', :scope => 'all'
130 assert_response :success
130 assert_response :success
131 assert_template 'index'
131 assert_template 'index'
132 assert assigns(:results).present?
132 assert assigns(:results).present?
133 end
133 end
134
134
135 def test_search_my_projects
135 def test_search_my_projects
136 @request.session[:user_id] = 2
136 @request.session[:user_id] = 2
137 get :index, :id => 1, :q => 'recipe subproject', :scope => 'my_projects', :all_words => ''
137 get :index, :id => 1, :q => 'recipe subproject', :scope => 'my_projects', :all_words => ''
138 assert_response :success
138 assert_response :success
139 assert_template 'index'
139 assert_template 'index'
140 assert assigns(:results).include?(Issue.find(1))
140 assert assigns(:results).include?(Issue.find(1))
141 assert !assigns(:results).include?(Issue.find(5))
141 assert !assigns(:results).include?(Issue.find(5))
142 end
142 end
143
143
144 def test_search_my_projects_without_memberships
144 def test_search_my_projects_without_memberships
145 # anonymous user has no memberships
145 # anonymous user has no memberships
146 get :index, :id => 1, :q => 'recipe subproject', :scope => 'my_projects', :all_words => ''
146 get :index, :id => 1, :q => 'recipe subproject', :scope => 'my_projects', :all_words => ''
147 assert_response :success
147 assert_response :success
148 assert_template 'index'
148 assert_template 'index'
149 assert assigns(:results).empty?
149 assert assigns(:results).empty?
150 end
150 end
151
151
152 def test_search_project_and_subprojects
152 def test_search_project_and_subprojects
153 get :index, :id => 1, :q => 'recipe subproject', :scope => 'subprojects', :all_words => ''
153 get :index, :id => 1, :q => 'recipe subproject', :scope => 'subprojects', :all_words => ''
154 assert_response :success
154 assert_response :success
155 assert_template 'index'
155 assert_template 'index'
156 assert assigns(:results).include?(Issue.find(1))
156 assert assigns(:results).include?(Issue.find(1))
157 assert assigns(:results).include?(Issue.find(5))
157 assert assigns(:results).include?(Issue.find(5))
158 end
158 end
159
159
160 def test_search_without_searchable_custom_fields
160 def test_search_without_searchable_custom_fields
161 CustomField.update_all "searchable = #{ActiveRecord::Base.connection.quoted_false}"
161 CustomField.update_all "searchable = #{ActiveRecord::Base.connection.quoted_false}"
162
162
163 get :index, :id => 1
163 get :index, :id => 1
164 assert_response :success
164 assert_response :success
165 assert_template 'index'
165 assert_template 'index'
166 assert_not_nil assigns(:project)
166 assert_not_nil assigns(:project)
167
167
168 get :index, :id => 1, :q => "can"
168 get :index, :id => 1, :q => "can"
169 assert_response :success
169 assert_response :success
170 assert_template 'index'
170 assert_template 'index'
171 end
171 end
172
172
173 def test_search_with_searchable_custom_fields
173 def test_search_with_searchable_custom_fields
174 get :index, :id => 1, :q => "stringforcustomfield"
174 get :index, :id => 1, :q => "stringforcustomfield"
175 assert_response :success
175 assert_response :success
176 results = assigns(:results)
176 results = assigns(:results)
177 assert_not_nil results
177 assert_not_nil results
178 assert_equal 1, results.size
178 assert_equal 1, results.size
179 assert results.include?(Issue.find(7))
179 assert results.include?(Issue.find(7))
180 end
180 end
181
181
182 def test_search_all_words
182 def test_search_all_words
183 # 'all words' is on by default
183 # 'all words' is on by default
184 get :index, :id => 1, :q => 'recipe updating saving', :all_words => '1'
184 get :index, :id => 1, :q => 'recipe updating saving', :all_words => '1'
185 assert_equal true, assigns(:all_words)
185 assert_equal true, assigns(:all_words)
186 results = assigns(:results)
186 results = assigns(:results)
187 assert_not_nil results
187 assert_not_nil results
188 assert_equal 1, results.size
188 assert_equal 1, results.size
189 assert results.include?(Issue.find(3))
189 assert results.include?(Issue.find(3))
190 end
190 end
191
191
192 def test_search_one_of_the_words
192 def test_search_one_of_the_words
193 get :index, :id => 1, :q => 'recipe updating saving', :all_words => ''
193 get :index, :id => 1, :q => 'recipe updating saving', :all_words => ''
194 assert_equal false, assigns(:all_words)
194 assert_equal false, assigns(:all_words)
195 results = assigns(:results)
195 results = assigns(:results)
196 assert_not_nil results
196 assert_not_nil results
197 assert_equal 3, results.size
197 assert_equal 3, results.size
198 assert results.include?(Issue.find(3))
198 assert results.include?(Issue.find(3))
199 end
199 end
200
200
201 def test_search_titles_only_without_result
201 def test_search_titles_only_without_result
202 get :index, :id => 1, :q => 'recipe updating saving', :titles_only => '1'
202 get :index, :id => 1, :q => 'recipe updating saving', :titles_only => '1'
203 results = assigns(:results)
203 results = assigns(:results)
204 assert_not_nil results
204 assert_not_nil results
205 assert_equal 0, results.size
205 assert_equal 0, results.size
206 end
206 end
207
207
208 def test_search_titles_only
208 def test_search_titles_only
209 get :index, :id => 1, :q => 'recipe', :titles_only => '1'
209 get :index, :id => 1, :q => 'recipe', :titles_only => '1'
210 assert_equal true, assigns(:titles_only)
210 assert_equal true, assigns(:titles_only)
211 results = assigns(:results)
211 results = assigns(:results)
212 assert_not_nil results
212 assert_not_nil results
213 assert_equal 2, results.size
213 assert_equal 2, results.size
214 end
214 end
215
215
216 def test_search_content
216 def test_search_content
217 Issue.where(:id => 1).update_all("description = 'This is a searchkeywordinthecontent'")
217 Issue.where(:id => 1).update_all("description = 'This is a searchkeywordinthecontent'")
218 get :index, :id => 1, :q => 'searchkeywordinthecontent', :titles_only => ''
218 get :index, :id => 1, :q => 'searchkeywordinthecontent', :titles_only => ''
219 assert_equal false, assigns(:titles_only)
219 assert_equal false, assigns(:titles_only)
220 results = assigns(:results)
220 results = assigns(:results)
221 assert_not_nil results
221 assert_not_nil results
222 assert_equal 1, results.size
222 assert_equal 1, results.size
223 end
223 end
224
224
225 def test_search_with_offset
225 def test_search_with_pagination
226 get :index, :q => 'coo', :offset => '20080806073000'
226 issue = (0..24).map {Issue.generate! :subject => 'search_with_limited_results'}.reverse
227
228 get :index, :q => 'search_with_limited_results'
227 assert_response :success
229 assert_response :success
228 results = assigns(:results)
230 assert_equal issue[0..9], assigns(:results)
229 assert results.any?
230 assert results.map(&:event_datetime).max < '20080806T073000'.to_time
231 end
232
231
233 def test_search_previous_with_offset
232 get :index, :q => 'search_with_limited_results', :page => 2
234 get :index, :q => 'coo', :offset => '20080806073000', :previous => '1'
235 assert_response :success
233 assert_response :success
236 results = assigns(:results)
234 assert_equal issue[10..19], assigns(:results)
237 assert results.any?
235
238 assert results.map(&:event_datetime).min >= '20080806T073000'.to_time
236 get :index, :q => 'search_with_limited_results', :page => 3
237 assert_response :success
238 assert_equal issue[20..24], assigns(:results)
239
240 get :index, :q => 'search_with_limited_results', :page => 4
241 assert_response :success
242 assert_equal [], assigns(:results)
239 end
243 end
240
244
241 def test_search_with_invalid_project_id
245 def test_search_with_invalid_project_id
242 get :index, :id => 195, :q => 'recipe'
246 get :index, :id => 195, :q => 'recipe'
243 assert_response 404
247 assert_response 404
244 assert_nil assigns(:results)
248 assert_nil assigns(:results)
245 end
249 end
246
250
247 def test_quick_jump_to_issue
251 def test_quick_jump_to_issue
248 # issue of a public project
252 # issue of a public project
249 get :index, :q => "3"
253 get :index, :q => "3"
250 assert_redirected_to '/issues/3'
254 assert_redirected_to '/issues/3'
251
255
252 # issue of a private project
256 # issue of a private project
253 get :index, :q => "4"
257 get :index, :q => "4"
254 assert_response :success
258 assert_response :success
255 assert_template 'index'
259 assert_template 'index'
256 end
260 end
257
261
258 def test_large_integer
262 def test_large_integer
259 get :index, :q => '4615713488'
263 get :index, :q => '4615713488'
260 assert_response :success
264 assert_response :success
261 assert_template 'index'
265 assert_template 'index'
262 end
266 end
263
267
264 def test_tokens_with_quotes
268 def test_tokens_with_quotes
265 get :index, :id => 1, :q => '"good bye" hello "bye bye"'
269 get :index, :id => 1, :q => '"good bye" hello "bye bye"'
266 assert_equal ["good bye", "hello", "bye bye"], assigns(:tokens)
270 assert_equal ["good bye", "hello", "bye bye"], assigns(:tokens)
267 end
271 end
268
272
269 def test_results_should_be_escaped_once
273 def test_results_should_be_escaped_once
270 assert Issue.find(1).update_attributes(:subject => '<subject> escaped_once', :description => '<description> escaped_once')
274 assert Issue.find(1).update_attributes(:subject => '<subject> escaped_once', :description => '<description> escaped_once')
271 get :index, :q => 'escaped_once'
275 get :index, :q => 'escaped_once'
272 assert_response :success
276 assert_response :success
273 assert_select '#search-results' do
277 assert_select '#search-results' do
274 assert_select 'dt.issue a', :text => /&lt;subject&gt;/
278 assert_select 'dt.issue a', :text => /&lt;subject&gt;/
275 assert_select 'dd', :text => /&lt;description&gt;/
279 assert_select 'dd', :text => /&lt;description&gt;/
276 end
280 end
277 end
281 end
278
282
279 def test_keywords_should_be_highlighted
283 def test_keywords_should_be_highlighted
280 assert Issue.find(1).update_attributes(:subject => 'subject highlighted', :description => 'description highlighted')
284 assert Issue.find(1).update_attributes(:subject => 'subject highlighted', :description => 'description highlighted')
281 get :index, :q => 'highlighted'
285 get :index, :q => 'highlighted'
282 assert_response :success
286 assert_response :success
283 assert_select '#search-results' do
287 assert_select '#search-results' do
284 assert_select 'dt.issue a span.highlight', :text => 'highlighted'
288 assert_select 'dt.issue a span.highlight', :text => 'highlighted'
285 assert_select 'dd span.highlight', :text => 'highlighted'
289 assert_select 'dd span.highlight', :text => 'highlighted'
286 end
290 end
287 end
291 end
288 end
292 end
@@ -1,146 +1,146
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class SearchTest < ActiveSupport::TestCase
20 class SearchTest < ActiveSupport::TestCase
21 fixtures :users,
21 fixtures :users,
22 :members,
22 :members,
23 :member_roles,
23 :member_roles,
24 :projects,
24 :projects,
25 :roles,
25 :roles,
26 :enabled_modules,
26 :enabled_modules,
27 :issues,
27 :issues,
28 :trackers,
28 :trackers,
29 :journals,
29 :journals,
30 :journal_details,
30 :journal_details,
31 :repositories,
31 :repositories,
32 :changesets
32 :changesets
33
33
34 def setup
34 def setup
35 @project = Project.find(1)
35 @project = Project.find(1)
36 @issue_keyword = '%unable to print recipes%'
36 @issue_keyword = '%unable to print recipes%'
37 @issue = Issue.find(1)
37 @issue = Issue.find(1)
38 @changeset_keyword = '%very first commit%'
38 @changeset_keyword = '%very first commit%'
39 @changeset = Changeset.find(100)
39 @changeset = Changeset.find(100)
40 end
40 end
41
41
42 def test_search_by_anonymous
42 def test_search_by_anonymous
43 User.current = nil
43 User.current = nil
44
44
45 r = Issue.search(@issue_keyword).first
45 r = Issue.search_results(@issue_keyword)
46 assert r.include?(@issue)
46 assert r.include?(@issue)
47 r = Changeset.search(@changeset_keyword).first
47 r = Changeset.search_results(@changeset_keyword)
48 assert r.include?(@changeset)
48 assert r.include?(@changeset)
49
49
50 # Removes the :view_changesets permission from Anonymous role
50 # Removes the :view_changesets permission from Anonymous role
51 remove_permission Role.anonymous, :view_changesets
51 remove_permission Role.anonymous, :view_changesets
52 User.current = nil
52 User.current = nil
53
53
54 r = Issue.search(@issue_keyword).first
54 r = Issue.search_results(@issue_keyword)
55 assert r.include?(@issue)
55 assert r.include?(@issue)
56 r = Changeset.search(@changeset_keyword).first
56 r = Changeset.search_results(@changeset_keyword)
57 assert !r.include?(@changeset)
57 assert !r.include?(@changeset)
58
58
59 # Make the project private
59 # Make the project private
60 @project.update_attribute :is_public, false
60 @project.update_attribute :is_public, false
61 r = Issue.search(@issue_keyword).first
61 r = Issue.search_results(@issue_keyword)
62 assert !r.include?(@issue)
62 assert !r.include?(@issue)
63 r = Changeset.search(@changeset_keyword).first
63 r = Changeset.search_results(@changeset_keyword)
64 assert !r.include?(@changeset)
64 assert !r.include?(@changeset)
65 end
65 end
66
66
67 def test_search_by_user
67 def test_search_by_user
68 User.current = User.find_by_login('rhill')
68 User.current = User.find_by_login('rhill')
69 assert User.current.memberships.empty?
69 assert User.current.memberships.empty?
70
70
71 r = Issue.search(@issue_keyword).first
71 r = Issue.search_results(@issue_keyword)
72 assert r.include?(@issue)
72 assert r.include?(@issue)
73 r = Changeset.search(@changeset_keyword).first
73 r = Changeset.search_results(@changeset_keyword)
74 assert r.include?(@changeset)
74 assert r.include?(@changeset)
75
75
76 # Removes the :view_changesets permission from Non member role
76 # Removes the :view_changesets permission from Non member role
77 remove_permission Role.non_member, :view_changesets
77 remove_permission Role.non_member, :view_changesets
78 User.current = User.find_by_login('rhill')
78 User.current = User.find_by_login('rhill')
79
79
80 r = Issue.search(@issue_keyword).first
80 r = Issue.search_results(@issue_keyword)
81 assert r.include?(@issue)
81 assert r.include?(@issue)
82 r = Changeset.search(@changeset_keyword).first
82 r = Changeset.search_results(@changeset_keyword)
83 assert !r.include?(@changeset)
83 assert !r.include?(@changeset)
84
84
85 # Make the project private
85 # Make the project private
86 @project.update_attribute :is_public, false
86 @project.update_attribute :is_public, false
87 r = Issue.search(@issue_keyword).first
87 r = Issue.search_results(@issue_keyword)
88 assert !r.include?(@issue)
88 assert !r.include?(@issue)
89 r = Changeset.search(@changeset_keyword).first
89 r = Changeset.search_results(@changeset_keyword)
90 assert !r.include?(@changeset)
90 assert !r.include?(@changeset)
91 end
91 end
92
92
93 def test_search_by_allowed_member
93 def test_search_by_allowed_member
94 User.current = User.find_by_login('jsmith')
94 User.current = User.find_by_login('jsmith')
95 assert User.current.projects.include?(@project)
95 assert User.current.projects.include?(@project)
96
96
97 r = Issue.search(@issue_keyword).first
97 r = Issue.search_results(@issue_keyword)
98 assert r.include?(@issue)
98 assert r.include?(@issue)
99 r = Changeset.search(@changeset_keyword).first
99 r = Changeset.search_results(@changeset_keyword)
100 assert r.include?(@changeset)
100 assert r.include?(@changeset)
101
101
102 # Make the project private
102 # Make the project private
103 @project.update_attribute :is_public, false
103 @project.update_attribute :is_public, false
104 r = Issue.search(@issue_keyword).first
104 r = Issue.search_results(@issue_keyword)
105 assert r.include?(@issue)
105 assert r.include?(@issue)
106 r = Changeset.search(@changeset_keyword).first
106 r = Changeset.search_results(@changeset_keyword)
107 assert r.include?(@changeset)
107 assert r.include?(@changeset)
108 end
108 end
109
109
110 def test_search_by_unallowed_member
110 def test_search_by_unallowed_member
111 # Removes the :view_changesets permission from user's and non member role
111 # Removes the :view_changesets permission from user's and non member role
112 remove_permission Role.find(1), :view_changesets
112 remove_permission Role.find(1), :view_changesets
113 remove_permission Role.non_member, :view_changesets
113 remove_permission Role.non_member, :view_changesets
114
114
115 User.current = User.find_by_login('jsmith')
115 User.current = User.find_by_login('jsmith')
116 assert User.current.projects.include?(@project)
116 assert User.current.projects.include?(@project)
117
117
118 r = Issue.search(@issue_keyword).first
118 r = Issue.search_results(@issue_keyword)
119 assert r.include?(@issue)
119 assert r.include?(@issue)
120 r = Changeset.search(@changeset_keyword).first
120 r = Changeset.search_results(@changeset_keyword)
121 assert !r.include?(@changeset)
121 assert !r.include?(@changeset)
122
122
123 # Make the project private
123 # Make the project private
124 @project.update_attribute :is_public, false
124 @project.update_attribute :is_public, false
125 r = Issue.search(@issue_keyword).first
125 r = Issue.search_results(@issue_keyword)
126 assert r.include?(@issue)
126 assert r.include?(@issue)
127 r = Changeset.search(@changeset_keyword).first
127 r = Changeset.search_results(@changeset_keyword)
128 assert !r.include?(@changeset)
128 assert !r.include?(@changeset)
129 end
129 end
130
130
131 def test_search_issue_with_multiple_hits_in_journals
131 def test_search_issue_with_multiple_hits_in_journals
132 i = Issue.find(1)
132 issue = Issue.find(1)
133 assert_equal 2, i.journals.where("notes LIKE '%notes%'").count
133 assert_equal 2, issue.journals.where("notes LIKE '%notes%'").count
134
134
135 r = Issue.search('%notes%').first
135 r = Issue.search_results('%notes%')
136 assert_equal 1, r.size
136 assert_equal 1, r.size
137 assert_equal i, r.first
137 assert_equal issue, r.first
138 end
138 end
139
139
140 private
140 private
141
141
142 def remove_permission(role, permission)
142 def remove_permission(role, permission)
143 role.permissions = role.permissions - [ permission ]
143 role.permissions = role.permissions - [ permission ]
144 role.save
144 role.save
145 end
145 end
146 end
146 end
General Comments 0
You need to be logged in to leave comments. Login now