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