##// END OF EJS Templates
Replaces find(:all) calls....
Jean-Philippe Lang -
r10690:536747b74708
parent child
Show More
@@ -1,322 +1,322
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 CustomField < ActiveRecord::Base
19 19 include Redmine::SubclassFactory
20 20
21 21 has_many :custom_values, :dependent => :delete_all
22 22 acts_as_list :scope => 'type = \'#{self.class}\''
23 23 serialize :possible_values
24 24
25 25 validates_presence_of :name, :field_format
26 26 validates_uniqueness_of :name, :scope => :type
27 27 validates_length_of :name, :maximum => 30
28 28 validates_inclusion_of :field_format, :in => Redmine::CustomFieldFormat.available_formats
29 29
30 30 validate :validate_custom_field
31 31 before_validation :set_searchable
32 32
33 33 scope :sorted, order("#{table_name}.position ASC")
34 34
35 35 CUSTOM_FIELDS_TABS = [
36 36 {:name => 'IssueCustomField', :partial => 'custom_fields/index',
37 37 :label => :label_issue_plural},
38 38 {:name => 'TimeEntryCustomField', :partial => 'custom_fields/index',
39 39 :label => :label_spent_time},
40 40 {:name => 'ProjectCustomField', :partial => 'custom_fields/index',
41 41 :label => :label_project_plural},
42 42 {:name => 'VersionCustomField', :partial => 'custom_fields/index',
43 43 :label => :label_version_plural},
44 44 {:name => 'UserCustomField', :partial => 'custom_fields/index',
45 45 :label => :label_user_plural},
46 46 {:name => 'GroupCustomField', :partial => 'custom_fields/index',
47 47 :label => :label_group_plural},
48 48 {:name => 'TimeEntryActivityCustomField', :partial => 'custom_fields/index',
49 49 :label => TimeEntryActivity::OptionName},
50 50 {:name => 'IssuePriorityCustomField', :partial => 'custom_fields/index',
51 51 :label => IssuePriority::OptionName},
52 52 {:name => 'DocumentCategoryCustomField', :partial => 'custom_fields/index',
53 53 :label => DocumentCategory::OptionName}
54 54 ]
55 55
56 56 CUSTOM_FIELDS_NAMES = CUSTOM_FIELDS_TABS.collect{|v| v[:name]}
57 57
58 58 def field_format=(arg)
59 59 # cannot change format of a saved custom field
60 60 super if new_record?
61 61 end
62 62
63 63 def set_searchable
64 64 # make sure these fields are not searchable
65 65 self.searchable = false if %w(int float date bool).include?(field_format)
66 66 # make sure only these fields can have multiple values
67 67 self.multiple = false unless %w(list user version).include?(field_format)
68 68 true
69 69 end
70 70
71 71 def validate_custom_field
72 72 if self.field_format == "list"
73 73 errors.add(:possible_values, :blank) if self.possible_values.nil? || self.possible_values.empty?
74 74 errors.add(:possible_values, :invalid) unless self.possible_values.is_a? Array
75 75 end
76 76
77 77 if regexp.present?
78 78 begin
79 79 Regexp.new(regexp)
80 80 rescue
81 81 errors.add(:regexp, :invalid)
82 82 end
83 83 end
84 84
85 85 if default_value.present? && !valid_field_value?(default_value)
86 86 errors.add(:default_value, :invalid)
87 87 end
88 88 end
89 89
90 90 def possible_values_options(obj=nil)
91 91 case field_format
92 92 when 'user', 'version'
93 93 if obj.respond_to?(:project) && obj.project
94 94 case field_format
95 95 when 'user'
96 96 obj.project.users.sort.collect {|u| [u.to_s, u.id.to_s]}
97 97 when 'version'
98 98 obj.project.shared_versions.sort.collect {|u| [u.to_s, u.id.to_s]}
99 99 end
100 100 elsif obj.is_a?(Array)
101 101 obj.collect {|o| possible_values_options(o)}.reduce(:&)
102 102 else
103 103 []
104 104 end
105 105 when 'bool'
106 106 [[l(:general_text_Yes), '1'], [l(:general_text_No), '0']]
107 107 else
108 108 possible_values || []
109 109 end
110 110 end
111 111
112 112 def possible_values(obj=nil)
113 113 case field_format
114 114 when 'user', 'version'
115 115 possible_values_options(obj).collect(&:last)
116 116 when 'bool'
117 117 ['1', '0']
118 118 else
119 119 values = super()
120 120 if values.is_a?(Array)
121 121 values.each do |value|
122 122 value.force_encoding('UTF-8') if value.respond_to?(:force_encoding)
123 123 end
124 124 end
125 125 values || []
126 126 end
127 127 end
128 128
129 129 # Makes possible_values accept a multiline string
130 130 def possible_values=(arg)
131 131 if arg.is_a?(Array)
132 132 super(arg.compact.collect(&:strip).select {|v| !v.blank?})
133 133 else
134 134 self.possible_values = arg.to_s.split(/[\n\r]+/)
135 135 end
136 136 end
137 137
138 138 def cast_value(value)
139 139 casted = nil
140 140 unless value.blank?
141 141 case field_format
142 142 when 'string', 'text', 'list'
143 143 casted = value
144 144 when 'date'
145 145 casted = begin; value.to_date; rescue; nil end
146 146 when 'bool'
147 147 casted = (value == '1' ? true : false)
148 148 when 'int'
149 149 casted = value.to_i
150 150 when 'float'
151 151 casted = value.to_f
152 152 when 'user', 'version'
153 153 casted = (value.blank? ? nil : field_format.classify.constantize.find_by_id(value.to_i))
154 154 end
155 155 end
156 156 casted
157 157 end
158 158
159 159 def value_from_keyword(keyword, customized)
160 160 possible_values_options = possible_values_options(customized)
161 161 if possible_values_options.present?
162 162 keyword = keyword.to_s.downcase
163 163 possible_values_options.detect {|text, id| text.downcase == keyword}.try(:last)
164 164 else
165 165 keyword
166 166 end
167 167 end
168 168
169 169 # Returns a ORDER BY clause that can used to sort customized
170 170 # objects by their value of the custom field.
171 171 # Returns nil if the custom field can not be used for sorting.
172 172 def order_statement
173 173 return nil if multiple?
174 174 case field_format
175 175 when 'string', 'text', 'list', 'date', 'bool'
176 176 # COALESCE is here to make sure that blank and NULL values are sorted equally
177 177 "COALESCE((SELECT cv_sort.value FROM #{CustomValue.table_name} cv_sort" +
178 178 " WHERE cv_sort.customized_type='#{self.class.customized_class.base_class.name}'" +
179 179 " AND cv_sort.customized_id=#{self.class.customized_class.table_name}.id" +
180 180 " AND cv_sort.custom_field_id=#{id} LIMIT 1), '')"
181 181 when 'int', 'float'
182 182 # Make the database cast values into numeric
183 183 # Postgresql will raise an error if a value can not be casted!
184 184 # CustomValue validations should ensure that it doesn't occur
185 185 "(SELECT CAST(cv_sort.value AS decimal(60,3)) FROM #{CustomValue.table_name} cv_sort" +
186 186 " WHERE cv_sort.customized_type='#{self.class.customized_class.base_class.name}'" +
187 187 " AND cv_sort.customized_id=#{self.class.customized_class.table_name}.id" +
188 188 " AND cv_sort.custom_field_id=#{id} AND cv_sort.value <> '' AND cv_sort.value IS NOT NULL LIMIT 1)"
189 189 when 'user', 'version'
190 190 value_class.fields_for_order_statement(value_join_alias)
191 191 else
192 192 nil
193 193 end
194 194 end
195 195
196 196 # Returns a GROUP BY clause that can used to group by custom value
197 197 # Returns nil if the custom field can not be used for grouping.
198 198 def group_statement
199 199 return nil if multiple?
200 200 case field_format
201 201 when 'list', 'date', 'bool', 'int'
202 202 order_statement
203 203 when 'user', 'version'
204 204 "COALESCE((SELECT cv_sort.value FROM #{CustomValue.table_name} cv_sort" +
205 205 " WHERE cv_sort.customized_type='#{self.class.customized_class.base_class.name}'" +
206 206 " AND cv_sort.customized_id=#{self.class.customized_class.table_name}.id" +
207 207 " AND cv_sort.custom_field_id=#{id} LIMIT 1), '')"
208 208 else
209 209 nil
210 210 end
211 211 end
212 212
213 213 def join_for_order_statement
214 214 case field_format
215 215 when 'user', 'version'
216 216 "LEFT OUTER JOIN #{CustomValue.table_name} #{join_alias}" +
217 217 " ON #{join_alias}.customized_type = '#{self.class.customized_class.base_class.name}'" +
218 218 " AND #{join_alias}.customized_id = #{self.class.customized_class.table_name}.id" +
219 219 " AND #{join_alias}.custom_field_id = #{id}" +
220 220 " AND #{join_alias}.value <> ''" +
221 221 " AND #{join_alias}.id = (SELECT max(#{join_alias}_2.id) FROM #{CustomValue.table_name} #{join_alias}_2" +
222 222 " WHERE #{join_alias}_2.customized_type = #{join_alias}.customized_type" +
223 223 " AND #{join_alias}_2.customized_id = #{join_alias}.customized_id" +
224 224 " AND #{join_alias}_2.custom_field_id = #{join_alias}.custom_field_id)" +
225 225 " LEFT OUTER JOIN #{value_class.table_name} #{value_join_alias}" +
226 226 " ON CAST(#{join_alias}.value as decimal(60,0)) = #{value_join_alias}.id"
227 227 else
228 228 nil
229 229 end
230 230 end
231 231
232 232 def join_alias
233 233 "cf_#{id}"
234 234 end
235 235
236 236 def value_join_alias
237 237 join_alias + "_" + field_format
238 238 end
239 239
240 240 def <=>(field)
241 241 position <=> field.position
242 242 end
243 243
244 244 # Returns the class that values represent
245 245 def value_class
246 246 case field_format
247 247 when 'user', 'version'
248 248 field_format.classify.constantize
249 249 else
250 250 nil
251 251 end
252 252 end
253 253
254 254 def self.customized_class
255 255 self.name =~ /^(.+)CustomField$/
256 256 begin; $1.constantize; rescue nil; end
257 257 end
258 258
259 259 # to move in project_custom_field
260 260 def self.for_all
261 find(:all, :conditions => ["is_for_all=?", true], :order => 'position')
261 where(:is_for_all => true).order('position').all
262 262 end
263 263
264 264 def type_name
265 265 nil
266 266 end
267 267
268 268 # Returns the error messages for the given value
269 269 # or an empty array if value is a valid value for the custom field
270 270 def validate_field_value(value)
271 271 errs = []
272 272 if value.is_a?(Array)
273 273 if !multiple?
274 274 errs << ::I18n.t('activerecord.errors.messages.invalid')
275 275 end
276 276 if is_required? && value.detect(&:present?).nil?
277 277 errs << ::I18n.t('activerecord.errors.messages.blank')
278 278 end
279 279 value.each {|v| errs += validate_field_value_format(v)}
280 280 else
281 281 if is_required? && value.blank?
282 282 errs << ::I18n.t('activerecord.errors.messages.blank')
283 283 end
284 284 errs += validate_field_value_format(value)
285 285 end
286 286 errs
287 287 end
288 288
289 289 # Returns true if value is a valid value for the custom field
290 290 def valid_field_value?(value)
291 291 validate_field_value(value).empty?
292 292 end
293 293
294 294 def format_in?(*args)
295 295 args.include?(field_format)
296 296 end
297 297
298 298 protected
299 299
300 300 # Returns the error message for the given value regarding its format
301 301 def validate_field_value_format(value)
302 302 errs = []
303 303 if value.present?
304 304 errs << ::I18n.t('activerecord.errors.messages.invalid') unless regexp.blank? or value =~ Regexp.new(regexp)
305 305 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => min_length) if min_length > 0 and value.length < min_length
306 306 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => max_length) if max_length > 0 and value.length > max_length
307 307
308 308 # Format specific validations
309 309 case field_format
310 310 when 'int'
311 311 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value =~ /^[+-]?\d+$/
312 312 when 'float'
313 313 begin; Kernel.Float(value); rescue; errs << ::I18n.t('activerecord.errors.messages.invalid') end
314 314 when 'date'
315 315 errs << ::I18n.t('activerecord.errors.messages.not_a_date') unless value =~ /^\d{4}-\d{2}-\d{2}$/ && begin; value.to_date; rescue; false end
316 316 when 'list'
317 317 errs << ::I18n.t('activerecord.errors.messages.inclusion') unless possible_values.include?(value)
318 318 end
319 319 end
320 320 errs
321 321 end
322 322 end
@@ -1,86 +1,89
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Group < Principal
19 19 include Redmine::SafeAttributes
20 20
21 21 has_and_belongs_to_many :users, :after_add => :user_added,
22 22 :after_remove => :user_removed
23 23
24 24 acts_as_customizable
25 25
26 26 validates_presence_of :lastname
27 27 validates_uniqueness_of :lastname, :case_sensitive => false
28 28 validates_length_of :lastname, :maximum => 30
29 29
30 30 before_destroy :remove_references_before_destroy
31 31
32 32 scope :sorted, order("#{table_name}.lastname ASC")
33 33
34 34 safe_attributes 'name',
35 35 'user_ids',
36 36 'custom_field_values',
37 37 'custom_fields',
38 38 :if => lambda {|group, user| user.admin?}
39 39
40 40 def to_s
41 41 lastname.to_s
42 42 end
43 43
44 44 def name
45 45 lastname
46 46 end
47 47
48 48 def name=(arg)
49 49 self.lastname = arg
50 50 end
51 51
52 52 def user_added(user)
53 53 members.each do |member|
54 54 next if member.project.nil?
55 55 user_member = Member.find_by_project_id_and_user_id(member.project_id, user.id) || Member.new(:project_id => member.project_id, :user_id => user.id)
56 56 member.member_roles.each do |member_role|
57 57 user_member.member_roles << MemberRole.new(:role => member_role.role, :inherited_from => member_role.id)
58 58 end
59 59 user_member.save!
60 60 end
61 61 end
62 62
63 63 def user_removed(user)
64 64 members.each do |member|
65 MemberRole.find(:all, :include => :member,
66 :conditions => ["#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids]).each(&:destroy)
65 MemberRole.
66 includes(:member).
67 where("#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids).
68 all.
69 each(&:destroy)
67 70 end
68 71 end
69 72
70 73 def self.human_attribute_name(attribute_key_name, *args)
71 74 attr_name = attribute_key_name.to_s
72 75 if attr_name == 'lastname'
73 76 attr_name = "name"
74 77 end
75 78 super(attr_name, *args)
76 79 end
77 80
78 81 private
79 82
80 83 # Removes references that are not handled by associations
81 84 def remove_references_before_destroy
82 85 return if self.id.nil?
83 86
84 87 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
85 88 end
86 89 end
@@ -1,498 +1,498
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 MailHandler < ActionMailer::Base
19 19 include ActionView::Helpers::SanitizeHelper
20 20 include Redmine::I18n
21 21
22 22 class UnauthorizedAction < StandardError; end
23 23 class MissingInformation < StandardError; end
24 24
25 25 attr_reader :email, :user
26 26
27 27 def self.receive(email, options={})
28 28 @@handler_options = options.dup
29 29
30 30 @@handler_options[:issue] ||= {}
31 31
32 32 if @@handler_options[:allow_override].is_a?(String)
33 33 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip)
34 34 end
35 35 @@handler_options[:allow_override] ||= []
36 36 # Project needs to be overridable if not specified
37 37 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
38 38 # Status overridable by default
39 39 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
40 40
41 41 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
42 42
43 43 email.force_encoding('ASCII-8BIT') if email.respond_to?(:force_encoding)
44 44 super(email)
45 45 end
46 46
47 47 def logger
48 48 Rails.logger
49 49 end
50 50
51 51 cattr_accessor :ignored_emails_headers
52 52 @@ignored_emails_headers = {
53 53 'X-Auto-Response-Suppress' => 'oof',
54 54 'Auto-Submitted' => /^auto-/
55 55 }
56 56
57 57 # Processes incoming emails
58 58 # Returns the created object (eg. an issue, a message) or false
59 59 def receive(email)
60 60 @email = email
61 61 sender_email = email.from.to_a.first.to_s.strip
62 62 # Ignore emails received from the application emission address to avoid hell cycles
63 63 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
64 64 if logger && logger.info
65 65 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]"
66 66 end
67 67 return false
68 68 end
69 69 # Ignore auto generated emails
70 70 self.class.ignored_emails_headers.each do |key, ignored_value|
71 71 value = email.header[key]
72 72 if value
73 73 value = value.to_s.downcase
74 74 if (ignored_value.is_a?(Regexp) && value.match(ignored_value)) || value == ignored_value
75 75 if logger && logger.info
76 76 logger.info "MailHandler: ignoring email with #{key}:#{value} header"
77 77 end
78 78 return false
79 79 end
80 80 end
81 81 end
82 82 @user = User.find_by_mail(sender_email) if sender_email.present?
83 83 if @user && !@user.active?
84 84 if logger && logger.info
85 85 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]"
86 86 end
87 87 return false
88 88 end
89 89 if @user.nil?
90 90 # Email was submitted by an unknown user
91 91 case @@handler_options[:unknown_user]
92 92 when 'accept'
93 93 @user = User.anonymous
94 94 when 'create'
95 95 @user = create_user_from_email
96 96 if @user
97 97 if logger && logger.info
98 98 logger.info "MailHandler: [#{@user.login}] account created"
99 99 end
100 100 Mailer.account_information(@user, @user.password).deliver
101 101 else
102 102 if logger && logger.error
103 103 logger.error "MailHandler: could not create account for [#{sender_email}]"
104 104 end
105 105 return false
106 106 end
107 107 else
108 108 # Default behaviour, emails from unknown users are ignored
109 109 if logger && logger.info
110 110 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]"
111 111 end
112 112 return false
113 113 end
114 114 end
115 115 User.current = @user
116 116 dispatch
117 117 end
118 118
119 119 private
120 120
121 121 MESSAGE_ID_RE = %r{^<?redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
122 122 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
123 123 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
124 124
125 125 def dispatch
126 126 headers = [email.in_reply_to, email.references].flatten.compact
127 127 subject = email.subject.to_s
128 128 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
129 129 klass, object_id = $1, $2.to_i
130 130 method_name = "receive_#{klass}_reply"
131 131 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
132 132 send method_name, object_id
133 133 else
134 134 # ignoring it
135 135 end
136 136 elsif m = subject.match(ISSUE_REPLY_SUBJECT_RE)
137 137 receive_issue_reply(m[1].to_i)
138 138 elsif m = subject.match(MESSAGE_REPLY_SUBJECT_RE)
139 139 receive_message_reply(m[1].to_i)
140 140 else
141 141 dispatch_to_default
142 142 end
143 143 rescue ActiveRecord::RecordInvalid => e
144 144 # TODO: send a email to the user
145 145 logger.error e.message if logger
146 146 false
147 147 rescue MissingInformation => e
148 148 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
149 149 false
150 150 rescue UnauthorizedAction => e
151 151 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
152 152 false
153 153 end
154 154
155 155 def dispatch_to_default
156 156 receive_issue
157 157 end
158 158
159 159 # Creates a new issue
160 160 def receive_issue
161 161 project = target_project
162 162 # check permission
163 163 unless @@handler_options[:no_permission_check]
164 164 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
165 165 end
166 166
167 167 issue = Issue.new(:author => user, :project => project)
168 168 issue.safe_attributes = issue_attributes_from_keywords(issue)
169 169 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
170 170 issue.subject = cleaned_up_subject
171 171 if issue.subject.blank?
172 172 issue.subject = '(no subject)'
173 173 end
174 174 issue.description = cleaned_up_text_body
175 175
176 176 # add To and Cc as watchers before saving so the watchers can reply to Redmine
177 177 add_watchers(issue)
178 178 issue.save!
179 179 add_attachments(issue)
180 180 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
181 181 issue
182 182 end
183 183
184 184 # Adds a note to an existing issue
185 185 def receive_issue_reply(issue_id, from_journal=nil)
186 186 issue = Issue.find_by_id(issue_id)
187 187 return unless issue
188 188 # check permission
189 189 unless @@handler_options[:no_permission_check]
190 190 unless user.allowed_to?(:add_issue_notes, issue.project) ||
191 191 user.allowed_to?(:edit_issues, issue.project)
192 192 raise UnauthorizedAction
193 193 end
194 194 end
195 195
196 196 # ignore CLI-supplied defaults for new issues
197 197 @@handler_options[:issue].clear
198 198
199 199 journal = issue.init_journal(user)
200 200 if from_journal && from_journal.private_notes?
201 201 # If the received email was a reply to a private note, make the added note private
202 202 issue.private_notes = true
203 203 end
204 204 issue.safe_attributes = issue_attributes_from_keywords(issue)
205 205 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
206 206 journal.notes = cleaned_up_text_body
207 207 add_attachments(issue)
208 208 issue.save!
209 209 if logger && logger.info
210 210 logger.info "MailHandler: issue ##{issue.id} updated by #{user}"
211 211 end
212 212 journal
213 213 end
214 214
215 215 # Reply will be added to the issue
216 216 def receive_journal_reply(journal_id)
217 217 journal = Journal.find_by_id(journal_id)
218 218 if journal && journal.journalized_type == 'Issue'
219 219 receive_issue_reply(journal.journalized_id, journal)
220 220 end
221 221 end
222 222
223 223 # Receives a reply to a forum message
224 224 def receive_message_reply(message_id)
225 225 message = Message.find_by_id(message_id)
226 226 if message
227 227 message = message.root
228 228
229 229 unless @@handler_options[:no_permission_check]
230 230 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
231 231 end
232 232
233 233 if !message.locked?
234 234 reply = Message.new(:subject => cleaned_up_subject.gsub(%r{^.*msg\d+\]}, '').strip,
235 235 :content => cleaned_up_text_body)
236 236 reply.author = user
237 237 reply.board = message.board
238 238 message.children << reply
239 239 add_attachments(reply)
240 240 reply
241 241 else
242 242 if logger && logger.info
243 243 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic"
244 244 end
245 245 end
246 246 end
247 247 end
248 248
249 249 def add_attachments(obj)
250 250 if email.attachments && email.attachments.any?
251 251 email.attachments.each do |attachment|
252 252 filename = attachment.filename
253 253 unless filename.respond_to?(:encoding)
254 254 # try to reencode to utf8 manually with ruby1.8
255 255 h = attachment.header['Content-Disposition']
256 256 unless h.nil?
257 257 begin
258 258 if m = h.value.match(/filename\*[0-9\*]*=([^=']+)'/)
259 259 filename = Redmine::CodesetUtil.to_utf8(filename, m[1])
260 260 elsif m = h.value.match(/filename=.*=\?([^\?]+)\?[BbQq]\?/)
261 261 # http://tools.ietf.org/html/rfc2047#section-4
262 262 filename = Redmine::CodesetUtil.to_utf8(filename, m[1])
263 263 end
264 264 rescue
265 265 # nop
266 266 end
267 267 end
268 268 end
269 269 obj.attachments << Attachment.create(:container => obj,
270 270 :file => attachment.decoded,
271 271 :filename => filename,
272 272 :author => user,
273 273 :content_type => attachment.mime_type)
274 274 end
275 275 end
276 276 end
277 277
278 278 # Adds To and Cc as watchers of the given object if the sender has the
279 279 # appropriate permission
280 280 def add_watchers(obj)
281 281 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
282 282 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
283 283 unless addresses.empty?
284 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
284 watchers = User.active.where('LOWER(mail) IN (?)', addresses).all
285 285 watchers.each {|w| obj.add_watcher(w)}
286 286 end
287 287 end
288 288 end
289 289
290 290 def get_keyword(attr, options={})
291 291 @keywords ||= {}
292 292 if @keywords.has_key?(attr)
293 293 @keywords[attr]
294 294 else
295 295 @keywords[attr] = begin
296 296 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) &&
297 297 (v = extract_keyword!(plain_text_body, attr, options[:format]))
298 298 v
299 299 elsif !@@handler_options[:issue][attr].blank?
300 300 @@handler_options[:issue][attr]
301 301 end
302 302 end
303 303 end
304 304 end
305 305
306 306 # Destructively extracts the value for +attr+ in +text+
307 307 # Returns nil if no matching keyword found
308 308 def extract_keyword!(text, attr, format=nil)
309 309 keys = [attr.to_s.humanize]
310 310 if attr.is_a?(Symbol)
311 311 if user && user.language.present?
312 312 keys << l("field_#{attr}", :default => '', :locale => user.language)
313 313 end
314 314 if Setting.default_language.present?
315 315 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language)
316 316 end
317 317 end
318 318 keys.reject! {|k| k.blank?}
319 319 keys.collect! {|k| Regexp.escape(k)}
320 320 format ||= '.+'
321 321 keyword = nil
322 322 regexp = /^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i
323 323 if m = text.match(regexp)
324 324 keyword = m[2].strip
325 325 text.gsub!(regexp, '')
326 326 end
327 327 keyword
328 328 end
329 329
330 330 def target_project
331 331 # TODO: other ways to specify project:
332 332 # * parse the email To field
333 333 # * specific project (eg. Setting.mail_handler_target_project)
334 334 target = Project.find_by_identifier(get_keyword(:project))
335 335 raise MissingInformation.new('Unable to determine target project') if target.nil?
336 336 target
337 337 end
338 338
339 339 # Returns a Hash of issue attributes extracted from keywords in the email body
340 340 def issue_attributes_from_keywords(issue)
341 341 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_assignee_from_keyword(k, issue)
342 342
343 343 attrs = {
344 344 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
345 345 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
346 346 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
347 347 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
348 348 'assigned_to_id' => assigned_to.try(:id),
349 349 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) &&
350 350 issue.project.shared_versions.named(k).first.try(:id),
351 351 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
352 352 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
353 353 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
354 354 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
355 355 }.delete_if {|k, v| v.blank? }
356 356
357 357 if issue.new_record? && attrs['tracker_id'].nil?
358 358 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
359 359 end
360 360
361 361 attrs
362 362 end
363 363
364 364 # Returns a Hash of issue custom field values extracted from keywords in the email body
365 365 def custom_field_values_from_keywords(customized)
366 366 customized.custom_field_values.inject({}) do |h, v|
367 367 if keyword = get_keyword(v.custom_field.name, :override => true)
368 368 h[v.custom_field.id.to_s] = v.custom_field.value_from_keyword(keyword, customized)
369 369 end
370 370 h
371 371 end
372 372 end
373 373
374 374 # Returns the text/plain part of the email
375 375 # If not found (eg. HTML-only email), returns the body with tags removed
376 376 def plain_text_body
377 377 return @plain_text_body unless @plain_text_body.nil?
378 378
379 379 part = email.text_part || email.html_part || email
380 380 @plain_text_body = Redmine::CodesetUtil.to_utf8(part.body.decoded, part.charset)
381 381
382 382 # strip html tags and remove doctype directive
383 383 @plain_text_body = strip_tags(@plain_text_body.strip)
384 384 @plain_text_body.sub! %r{^<!DOCTYPE .*$}, ''
385 385 @plain_text_body
386 386 end
387 387
388 388 def cleaned_up_text_body
389 389 cleanup_body(plain_text_body)
390 390 end
391 391
392 392 def cleaned_up_subject
393 393 subject = email.subject.to_s
394 394 unless subject.respond_to?(:encoding)
395 395 # try to reencode to utf8 manually with ruby1.8
396 396 begin
397 397 if h = email.header[:subject]
398 398 # http://tools.ietf.org/html/rfc2047#section-4
399 399 if m = h.value.match(/=\?([^\?]+)\?[BbQq]\?/)
400 400 subject = Redmine::CodesetUtil.to_utf8(subject, m[1])
401 401 end
402 402 end
403 403 rescue
404 404 # nop
405 405 end
406 406 end
407 407 subject.strip[0,255]
408 408 end
409 409
410 410 def self.full_sanitizer
411 411 @full_sanitizer ||= HTML::FullSanitizer.new
412 412 end
413 413
414 414 def self.assign_string_attribute_with_limit(object, attribute, value, limit=nil)
415 415 limit ||= object.class.columns_hash[attribute.to_s].limit || 255
416 416 value = value.to_s.slice(0, limit)
417 417 object.send("#{attribute}=", value)
418 418 end
419 419
420 420 # Returns a User from an email address and a full name
421 421 def self.new_user_from_attributes(email_address, fullname=nil)
422 422 user = User.new
423 423
424 424 # Truncating the email address would result in an invalid format
425 425 user.mail = email_address
426 426 assign_string_attribute_with_limit(user, 'login', email_address, User::LOGIN_LENGTH_LIMIT)
427 427
428 428 names = fullname.blank? ? email_address.gsub(/@.*$/, '').split('.') : fullname.split
429 429 assign_string_attribute_with_limit(user, 'firstname', names.shift)
430 430 assign_string_attribute_with_limit(user, 'lastname', names.join(' '))
431 431 user.lastname = '-' if user.lastname.blank?
432 432
433 433 password_length = [Setting.password_min_length.to_i, 10].max
434 434 user.password = Redmine::Utils.random_hex(password_length / 2 + 1)
435 435 user.language = Setting.default_language
436 436
437 437 unless user.valid?
438 438 user.login = "user#{Redmine::Utils.random_hex(6)}" unless user.errors[:login].blank?
439 439 user.firstname = "-" unless user.errors[:firstname].blank?
440 440 user.lastname = "-" unless user.errors[:lastname].blank?
441 441 end
442 442
443 443 user
444 444 end
445 445
446 446 # Creates a User for the +email+ sender
447 447 # Returns the user or nil if it could not be created
448 448 def create_user_from_email
449 449 from = email.header['from'].to_s
450 450 addr, name = from, nil
451 451 if m = from.match(/^"?(.+?)"?\s+<(.+@.+)>$/)
452 452 addr, name = m[2], m[1]
453 453 end
454 454 if addr.present?
455 455 user = self.class.new_user_from_attributes(addr, name)
456 456 if user.save
457 457 user
458 458 else
459 459 logger.error "MailHandler: failed to create User: #{user.errors.full_messages}" if logger
460 460 nil
461 461 end
462 462 else
463 463 logger.error "MailHandler: failed to create User: no FROM address found" if logger
464 464 nil
465 465 end
466 466 end
467 467
468 468 # Removes the email body of text after the truncation configurations.
469 469 def cleanup_body(body)
470 470 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
471 471 unless delimiters.empty?
472 472 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
473 473 body = body.gsub(regex, '')
474 474 end
475 475 body.strip
476 476 end
477 477
478 478 def find_assignee_from_keyword(keyword, issue)
479 479 keyword = keyword.to_s.downcase
480 480 assignable = issue.assignable_users
481 481 assignee = nil
482 482 assignee ||= assignable.detect {|a|
483 483 a.mail.to_s.downcase == keyword ||
484 484 a.login.to_s.downcase == keyword
485 485 }
486 486 if assignee.nil? && keyword.match(/ /)
487 487 firstname, lastname = *(keyword.split) # "First Last Throwaway"
488 488 assignee ||= assignable.detect {|a|
489 489 a.is_a?(User) && a.firstname.to_s.downcase == firstname &&
490 490 a.lastname.to_s.downcase == lastname
491 491 }
492 492 end
493 493 if assignee.nil?
494 494 assignee ||= assignable.detect {|a| a.name.downcase == keyword}
495 495 end
496 496 assignee
497 497 end
498 498 end
@@ -1,474 +1,474
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Mailer < ActionMailer::Base
19 19 layout 'mailer'
20 20 helper :application
21 21 helper :issues
22 22 helper :custom_fields
23 23
24 24 include Redmine::I18n
25 25
26 26 def self.default_url_options
27 27 { :host => Setting.host_name, :protocol => Setting.protocol }
28 28 end
29 29
30 30 # Builds a Mail::Message object used to email recipients of the added issue.
31 31 #
32 32 # Example:
33 33 # issue_add(issue) => Mail::Message object
34 34 # Mailer.issue_add(issue).deliver => sends an email to issue recipients
35 35 def issue_add(issue)
36 36 redmine_headers 'Project' => issue.project.identifier,
37 37 'Issue-Id' => issue.id,
38 38 'Issue-Author' => issue.author.login
39 39 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
40 40 message_id issue
41 41 @author = issue.author
42 42 @issue = issue
43 43 @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue)
44 44 recipients = issue.recipients
45 45 cc = issue.watcher_recipients - recipients
46 46 mail :to => recipients,
47 47 :cc => cc,
48 48 :subject => "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
49 49 end
50 50
51 51 # Builds a Mail::Message object used to email recipients of the edited issue.
52 52 #
53 53 # Example:
54 54 # issue_edit(journal) => Mail::Message object
55 55 # Mailer.issue_edit(journal).deliver => sends an email to issue recipients
56 56 def issue_edit(journal)
57 57 issue = journal.journalized.reload
58 58 redmine_headers 'Project' => issue.project.identifier,
59 59 'Issue-Id' => issue.id,
60 60 'Issue-Author' => issue.author.login
61 61 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
62 62 message_id journal
63 63 references issue
64 64 @author = journal.user
65 65 recipients = journal.recipients
66 66 # Watchers in cc
67 67 cc = journal.watcher_recipients - recipients
68 68 s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
69 69 s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
70 70 s << issue.subject
71 71 @issue = issue
72 72 @journal = journal
73 73 @issue_url = url_for(:controller => 'issues', :action => 'show', :id => issue, :anchor => "change-#{journal.id}")
74 74 mail :to => recipients,
75 75 :cc => cc,
76 76 :subject => s
77 77 end
78 78
79 79 def reminder(user, issues, days)
80 80 set_language_if_valid user.language
81 81 @issues = issues
82 82 @days = days
83 83 @issues_url = url_for(:controller => 'issues', :action => 'index',
84 84 :set_filter => 1, :assigned_to_id => user.id,
85 85 :sort => 'due_date:asc')
86 86 mail :to => user.mail,
87 87 :subject => l(:mail_subject_reminder, :count => issues.size, :days => days)
88 88 end
89 89
90 90 # Builds a Mail::Message object used to email users belonging to the added document's project.
91 91 #
92 92 # Example:
93 93 # document_added(document) => Mail::Message object
94 94 # Mailer.document_added(document).deliver => sends an email to the document's project recipients
95 95 def document_added(document)
96 96 redmine_headers 'Project' => document.project.identifier
97 97 @author = User.current
98 98 @document = document
99 99 @document_url = url_for(:controller => 'documents', :action => 'show', :id => document)
100 100 mail :to => document.recipients,
101 101 :subject => "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
102 102 end
103 103
104 104 # Builds a Mail::Message object used to email recipients of a project when an attachements are added.
105 105 #
106 106 # Example:
107 107 # attachments_added(attachments) => Mail::Message object
108 108 # Mailer.attachments_added(attachments).deliver => sends an email to the project's recipients
109 109 def attachments_added(attachments)
110 110 container = attachments.first.container
111 111 added_to = ''
112 112 added_to_url = ''
113 113 @author = attachments.first.author
114 114 case container.class.name
115 115 when 'Project'
116 116 added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container)
117 117 added_to = "#{l(:label_project)}: #{container}"
118 118 recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect {|u| u.mail}
119 119 when 'Version'
120 120 added_to_url = url_for(:controller => 'files', :action => 'index', :project_id => container.project)
121 121 added_to = "#{l(:label_version)}: #{container.name}"
122 122 recipients = container.project.notified_users.select {|user| user.allowed_to?(:view_files, container.project)}.collect {|u| u.mail}
123 123 when 'Document'
124 124 added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id)
125 125 added_to = "#{l(:label_document)}: #{container.title}"
126 126 recipients = container.recipients
127 127 end
128 128 redmine_headers 'Project' => container.project.identifier
129 129 @attachments = attachments
130 130 @added_to = added_to
131 131 @added_to_url = added_to_url
132 132 mail :to => recipients,
133 133 :subject => "[#{container.project.name}] #{l(:label_attachment_new)}"
134 134 end
135 135
136 136 # Builds a Mail::Message object used to email recipients of a news' project when a news item is added.
137 137 #
138 138 # Example:
139 139 # news_added(news) => Mail::Message object
140 140 # Mailer.news_added(news).deliver => sends an email to the news' project recipients
141 141 def news_added(news)
142 142 redmine_headers 'Project' => news.project.identifier
143 143 @author = news.author
144 144 message_id news
145 145 @news = news
146 146 @news_url = url_for(:controller => 'news', :action => 'show', :id => news)
147 147 mail :to => news.recipients,
148 148 :subject => "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
149 149 end
150 150
151 151 # Builds a Mail::Message object used to email recipients of a news' project when a news comment is added.
152 152 #
153 153 # Example:
154 154 # news_comment_added(comment) => Mail::Message object
155 155 # Mailer.news_comment_added(comment) => sends an email to the news' project recipients
156 156 def news_comment_added(comment)
157 157 news = comment.commented
158 158 redmine_headers 'Project' => news.project.identifier
159 159 @author = comment.author
160 160 message_id comment
161 161 @news = news
162 162 @comment = comment
163 163 @news_url = url_for(:controller => 'news', :action => 'show', :id => news)
164 164 mail :to => news.recipients,
165 165 :cc => news.watcher_recipients,
166 166 :subject => "Re: [#{news.project.name}] #{l(:label_news)}: #{news.title}"
167 167 end
168 168
169 169 # Builds a Mail::Message object used to email the recipients of the specified message that was posted.
170 170 #
171 171 # Example:
172 172 # message_posted(message) => Mail::Message object
173 173 # Mailer.message_posted(message).deliver => sends an email to the recipients
174 174 def message_posted(message)
175 175 redmine_headers 'Project' => message.project.identifier,
176 176 'Topic-Id' => (message.parent_id || message.id)
177 177 @author = message.author
178 178 message_id message
179 179 references message.parent unless message.parent.nil?
180 180 recipients = message.recipients
181 181 cc = ((message.root.watcher_recipients + message.board.watcher_recipients).uniq - recipients)
182 182 @message = message
183 183 @message_url = url_for(message.event_url)
184 184 mail :to => recipients,
185 185 :cc => cc,
186 186 :subject => "[#{message.board.project.name} - #{message.board.name} - msg#{message.root.id}] #{message.subject}"
187 187 end
188 188
189 189 # Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was added.
190 190 #
191 191 # Example:
192 192 # wiki_content_added(wiki_content) => Mail::Message object
193 193 # Mailer.wiki_content_added(wiki_content).deliver => sends an email to the project's recipients
194 194 def wiki_content_added(wiki_content)
195 195 redmine_headers 'Project' => wiki_content.project.identifier,
196 196 'Wiki-Page-Id' => wiki_content.page.id
197 197 @author = wiki_content.author
198 198 message_id wiki_content
199 199 recipients = wiki_content.recipients
200 200 cc = wiki_content.page.wiki.watcher_recipients - recipients
201 201 @wiki_content = wiki_content
202 202 @wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
203 203 :project_id => wiki_content.project,
204 204 :id => wiki_content.page.title)
205 205 mail :to => recipients,
206 206 :cc => cc,
207 207 :subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_added, :id => wiki_content.page.pretty_title)}"
208 208 end
209 209
210 210 # Builds a Mail::Message object used to email the recipients of a project of the specified wiki content was updated.
211 211 #
212 212 # Example:
213 213 # wiki_content_updated(wiki_content) => Mail::Message object
214 214 # Mailer.wiki_content_updated(wiki_content).deliver => sends an email to the project's recipients
215 215 def wiki_content_updated(wiki_content)
216 216 redmine_headers 'Project' => wiki_content.project.identifier,
217 217 'Wiki-Page-Id' => wiki_content.page.id
218 218 @author = wiki_content.author
219 219 message_id wiki_content
220 220 recipients = wiki_content.recipients
221 221 cc = wiki_content.page.wiki.watcher_recipients + wiki_content.page.watcher_recipients - recipients
222 222 @wiki_content = wiki_content
223 223 @wiki_content_url = url_for(:controller => 'wiki', :action => 'show',
224 224 :project_id => wiki_content.project,
225 225 :id => wiki_content.page.title)
226 226 @wiki_diff_url = url_for(:controller => 'wiki', :action => 'diff',
227 227 :project_id => wiki_content.project, :id => wiki_content.page.title,
228 228 :version => wiki_content.version)
229 229 mail :to => recipients,
230 230 :cc => cc,
231 231 :subject => "[#{wiki_content.project.name}] #{l(:mail_subject_wiki_content_updated, :id => wiki_content.page.pretty_title)}"
232 232 end
233 233
234 234 # Builds a Mail::Message object used to email the specified user their account information.
235 235 #
236 236 # Example:
237 237 # account_information(user, password) => Mail::Message object
238 238 # Mailer.account_information(user, password).deliver => sends account information to the user
239 239 def account_information(user, password)
240 240 set_language_if_valid user.language
241 241 @user = user
242 242 @password = password
243 243 @login_url = url_for(:controller => 'account', :action => 'login')
244 244 mail :to => user.mail,
245 245 :subject => l(:mail_subject_register, Setting.app_title)
246 246 end
247 247
248 248 # Builds a Mail::Message object used to email all active administrators of an account activation request.
249 249 #
250 250 # Example:
251 251 # account_activation_request(user) => Mail::Message object
252 252 # Mailer.account_activation_request(user).deliver => sends an email to all active administrators
253 253 def account_activation_request(user)
254 254 # Send the email to all active administrators
255 recipients = User.active.find(:all, :conditions => {:admin => true}).collect { |u| u.mail }.compact
255 recipients = User.active.where(:admin => true).all.collect { |u| u.mail }.compact
256 256 @user = user
257 257 @url = url_for(:controller => 'users', :action => 'index',
258 258 :status => User::STATUS_REGISTERED,
259 259 :sort_key => 'created_on', :sort_order => 'desc')
260 260 mail :to => recipients,
261 261 :subject => l(:mail_subject_account_activation_request, Setting.app_title)
262 262 end
263 263
264 264 # Builds a Mail::Message object used to email the specified user that their account was activated by an administrator.
265 265 #
266 266 # Example:
267 267 # account_activated(user) => Mail::Message object
268 268 # Mailer.account_activated(user).deliver => sends an email to the registered user
269 269 def account_activated(user)
270 270 set_language_if_valid user.language
271 271 @user = user
272 272 @login_url = url_for(:controller => 'account', :action => 'login')
273 273 mail :to => user.mail,
274 274 :subject => l(:mail_subject_register, Setting.app_title)
275 275 end
276 276
277 277 def lost_password(token)
278 278 set_language_if_valid(token.user.language)
279 279 @token = token
280 280 @url = url_for(:controller => 'account', :action => 'lost_password', :token => token.value)
281 281 mail :to => token.user.mail,
282 282 :subject => l(:mail_subject_lost_password, Setting.app_title)
283 283 end
284 284
285 285 def register(token)
286 286 set_language_if_valid(token.user.language)
287 287 @token = token
288 288 @url = url_for(:controller => 'account', :action => 'activate', :token => token.value)
289 289 mail :to => token.user.mail,
290 290 :subject => l(:mail_subject_register, Setting.app_title)
291 291 end
292 292
293 293 def test_email(user)
294 294 set_language_if_valid(user.language)
295 295 @url = url_for(:controller => 'welcome')
296 296 mail :to => user.mail,
297 297 :subject => 'Redmine test'
298 298 end
299 299
300 300 # Overrides default deliver! method to prevent from sending an email
301 301 # with no recipient, cc or bcc
302 302 def deliver!(mail = @mail)
303 303 set_language_if_valid @initial_language
304 304 return false if (recipients.nil? || recipients.empty?) &&
305 305 (cc.nil? || cc.empty?) &&
306 306 (bcc.nil? || bcc.empty?)
307 307
308 308
309 309 # Log errors when raise_delivery_errors is set to false, Rails does not
310 310 raise_errors = self.class.raise_delivery_errors
311 311 self.class.raise_delivery_errors = true
312 312 begin
313 313 return super(mail)
314 314 rescue Exception => e
315 315 if raise_errors
316 316 raise e
317 317 elsif mylogger
318 318 mylogger.error "The following error occured while sending email notification: \"#{e.message}\". Check your configuration in config/configuration.yml."
319 319 end
320 320 ensure
321 321 self.class.raise_delivery_errors = raise_errors
322 322 end
323 323 end
324 324
325 325 # Sends reminders to issue assignees
326 326 # Available options:
327 327 # * :days => how many days in the future to remind about (defaults to 7)
328 328 # * :tracker => id of tracker for filtering issues (defaults to all trackers)
329 329 # * :project => id or identifier of project to process (defaults to all projects)
330 330 # * :users => array of user/group ids who should be reminded
331 331 def self.reminders(options={})
332 332 days = options[:days] || 7
333 333 project = options[:project] ? Project.find(options[:project]) : nil
334 334 tracker = options[:tracker] ? Tracker.find(options[:tracker]) : nil
335 335 user_ids = options[:users]
336 336
337 337 scope = Issue.open.where("#{Issue.table_name}.assigned_to_id IS NOT NULL" +
338 338 " AND #{Project.table_name}.status = #{Project::STATUS_ACTIVE}" +
339 339 " AND #{Issue.table_name}.due_date <= ?", days.day.from_now.to_date
340 340 )
341 341 scope = scope.where(:assigned_to_id => user_ids) if user_ids.present?
342 342 scope = scope.where(:project_id => project.id) if project
343 343 scope = scope.where(:tracker_id => tracker.id) if tracker
344 344
345 345 issues_by_assignee = scope.includes(:status, :assigned_to, :project, :tracker).all.group_by(&:assigned_to)
346 346 issues_by_assignee.keys.each do |assignee|
347 347 if assignee.is_a?(Group)
348 348 assignee.users.each do |user|
349 349 issues_by_assignee[user] ||= []
350 350 issues_by_assignee[user] += issues_by_assignee[assignee]
351 351 end
352 352 end
353 353 end
354 354
355 355 issues_by_assignee.each do |assignee, issues|
356 356 reminder(assignee, issues, days).deliver if assignee.is_a?(User) && assignee.active?
357 357 end
358 358 end
359 359
360 360 # Activates/desactivates email deliveries during +block+
361 361 def self.with_deliveries(enabled = true, &block)
362 362 was_enabled = ActionMailer::Base.perform_deliveries
363 363 ActionMailer::Base.perform_deliveries = !!enabled
364 364 yield
365 365 ensure
366 366 ActionMailer::Base.perform_deliveries = was_enabled
367 367 end
368 368
369 369 # Sends emails synchronously in the given block
370 370 def self.with_synched_deliveries(&block)
371 371 saved_method = ActionMailer::Base.delivery_method
372 372 if m = saved_method.to_s.match(%r{^async_(.+)$})
373 373 synched_method = m[1]
374 374 ActionMailer::Base.delivery_method = synched_method.to_sym
375 375 ActionMailer::Base.send "#{synched_method}_settings=", ActionMailer::Base.send("async_#{synched_method}_settings")
376 376 end
377 377 yield
378 378 ensure
379 379 ActionMailer::Base.delivery_method = saved_method
380 380 end
381 381
382 382 def mail(headers={})
383 383 headers.merge! 'X-Mailer' => 'Redmine',
384 384 'X-Redmine-Host' => Setting.host_name,
385 385 'X-Redmine-Site' => Setting.app_title,
386 386 'X-Auto-Response-Suppress' => 'OOF',
387 387 'Auto-Submitted' => 'auto-generated',
388 388 'From' => Setting.mail_from,
389 389 'List-Id' => "<#{Setting.mail_from.to_s.gsub('@', '.')}>"
390 390
391 391 # Removes the author from the recipients and cc
392 392 # if he doesn't want to receive notifications about what he does
393 393 if @author && @author.logged? && @author.pref[:no_self_notified]
394 394 headers[:to].delete(@author.mail) if headers[:to].is_a?(Array)
395 395 headers[:cc].delete(@author.mail) if headers[:cc].is_a?(Array)
396 396 end
397 397
398 398 if @author && @author.logged?
399 399 redmine_headers 'Sender' => @author.login
400 400 end
401 401
402 402 # Blind carbon copy recipients
403 403 if Setting.bcc_recipients?
404 404 headers[:bcc] = [headers[:to], headers[:cc]].flatten.uniq.reject(&:blank?)
405 405 headers[:to] = nil
406 406 headers[:cc] = nil
407 407 end
408 408
409 409 if @message_id_object
410 410 headers[:message_id] = "<#{self.class.message_id_for(@message_id_object)}>"
411 411 end
412 412 if @references_objects
413 413 headers[:references] = @references_objects.collect {|o| "<#{self.class.message_id_for(o)}>"}.join(' ')
414 414 end
415 415
416 416 super headers do |format|
417 417 format.text
418 418 format.html unless Setting.plain_text_mail?
419 419 end
420 420
421 421 set_language_if_valid @initial_language
422 422 end
423 423
424 424 def initialize(*args)
425 425 @initial_language = current_language
426 426 set_language_if_valid Setting.default_language
427 427 super
428 428 end
429 429
430 430 def self.deliver_mail(mail)
431 431 return false if mail.to.blank? && mail.cc.blank? && mail.bcc.blank?
432 432 super
433 433 end
434 434
435 435 def self.method_missing(method, *args, &block)
436 436 if m = method.to_s.match(%r{^deliver_(.+)$})
437 437 ActiveSupport::Deprecation.warn "Mailer.deliver_#{m[1]}(*args) is deprecated. Use Mailer.#{m[1]}(*args).deliver instead."
438 438 send(m[1], *args).deliver
439 439 else
440 440 super
441 441 end
442 442 end
443 443
444 444 private
445 445
446 446 # Appends a Redmine header field (name is prepended with 'X-Redmine-')
447 447 def redmine_headers(h)
448 448 h.each { |k,v| headers["X-Redmine-#{k}"] = v.to_s }
449 449 end
450 450
451 451 # Returns a predictable Message-Id for the given object
452 452 def self.message_id_for(object)
453 453 # id + timestamp should reduce the odds of a collision
454 454 # as far as we don't send multiple emails for the same object
455 455 timestamp = object.send(object.respond_to?(:created_on) ? :created_on : :updated_on)
456 456 hash = "redmine.#{object.class.name.demodulize.underscore}-#{object.id}.#{timestamp.strftime("%Y%m%d%H%M%S")}"
457 457 host = Setting.mail_from.to_s.gsub(%r{^.*@}, '')
458 458 host = "#{::Socket.gethostname}.redmine" if host.empty?
459 459 "#{hash}@#{host}"
460 460 end
461 461
462 462 def message_id(object)
463 463 @message_id_object = object
464 464 end
465 465
466 466 def references(object)
467 467 @references_objects ||= []
468 468 @references_objects << object
469 469 end
470 470
471 471 def mylogger
472 472 Rails.logger
473 473 end
474 474 end
@@ -1,64 +1,64
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 MemberRole < ActiveRecord::Base
19 19 belongs_to :member
20 20 belongs_to :role
21 21
22 22 after_destroy :remove_member_if_empty
23 23
24 24 after_create :add_role_to_group_users
25 25 after_destroy :remove_role_from_group_users
26 26
27 27 validates_presence_of :role
28 28 validate :validate_role_member
29 29
30 30 def validate_role_member
31 31 errors.add :role_id, :invalid if role && !role.member?
32 32 end
33 33
34 34 def inherited?
35 35 !inherited_from.nil?
36 36 end
37 37
38 38 private
39 39
40 40 def remove_member_if_empty
41 41 if member.roles.empty?
42 42 member.destroy
43 43 end
44 44 end
45 45
46 46 def add_role_to_group_users
47 47 if member.principal.is_a?(Group)
48 48 member.principal.users.each do |user|
49 49 user_member = Member.find_by_project_id_and_user_id(member.project_id, user.id) || Member.new(:project_id => member.project_id, :user_id => user.id)
50 50 user_member.member_roles << MemberRole.new(:role => role, :inherited_from => id)
51 51 user_member.save!
52 52 end
53 53 end
54 54 end
55 55
56 56 def remove_role_from_group_users
57 MemberRole.find(:all, :conditions => { :inherited_from => id }).group_by(&:member).each do |member, member_roles|
57 MemberRole.where(:inherited_from => id).all.group_by(&:member).each do |member, member_roles|
58 58 member_roles.each(&:destroy)
59 59 if member && member.user
60 60 Watcher.prune(:user => member.user, :project => member.project)
61 61 end
62 62 end
63 63 end
64 64 end
@@ -1,974 +1,972
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_CLOSED = 5
24 24 STATUS_ARCHIVED = 9
25 25
26 26 # Maximum length for project identifiers
27 27 IDENTIFIER_MAX_LENGTH = 100
28 28
29 29 # Specific overidden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members, :include => [:principal, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
32 32 has_many :memberships, :class_name => 'Member'
33 33 has_many :member_principals, :class_name => 'Member',
34 34 :include => :principal,
35 35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
36 36 has_many :users, :through => :members
37 37 has_many :principals, :through => :member_principals, :source => :principal
38 38
39 39 has_many :enabled_modules, :dependent => :delete_all
40 40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
41 41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
42 42 has_many :issue_changes, :through => :issues, :source => :journals
43 43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
44 44 has_many :time_entries, :dependent => :delete_all
45 45 has_many :queries, :dependent => :delete_all
46 46 has_many :documents, :dependent => :destroy
47 47 has_many :news, :dependent => :destroy, :include => :author
48 48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
49 49 has_many :boards, :dependent => :destroy, :order => "position ASC"
50 50 has_one :repository, :conditions => ["is_default = ?", true]
51 51 has_many :repositories, :dependent => :destroy
52 52 has_many :changesets, :through => :repository
53 53 has_one :wiki, :dependent => :destroy
54 54 # Custom field for the project issues
55 55 has_and_belongs_to_many :issue_custom_fields,
56 56 :class_name => 'IssueCustomField',
57 57 :order => "#{CustomField.table_name}.position",
58 58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
59 59 :association_foreign_key => 'custom_field_id'
60 60
61 61 acts_as_nested_set :order => 'name', :dependent => :destroy
62 62 acts_as_attachable :view_permission => :view_files,
63 63 :delete_permission => :manage_files
64 64
65 65 acts_as_customizable
66 66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
67 67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
68 68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
69 69 :author => nil
70 70
71 71 attr_protected :status
72 72
73 73 validates_presence_of :name, :identifier
74 74 validates_uniqueness_of :identifier
75 75 validates_associated :repository, :wiki
76 76 validates_length_of :name, :maximum => 255
77 77 validates_length_of :homepage, :maximum => 255
78 78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
79 79 # donwcase letters, digits, dashes but not digits only
80 80 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
81 81 # reserved words
82 82 validates_exclusion_of :identifier, :in => %w( new )
83 83
84 84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
85 85 before_destroy :delete_all_members
86 86
87 87 scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
88 88 scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
89 89 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
90 90 scope :all_public, { :conditions => { :is_public => true } }
91 91 scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
92 92 scope :allowed_to, lambda {|*args|
93 93 user = User.current
94 94 permission = nil
95 95 if args.first.is_a?(Symbol)
96 96 permission = args.shift
97 97 else
98 98 user = args.shift
99 99 permission = args.shift
100 100 end
101 101 { :conditions => Project.allowed_to_condition(user, permission, *args) }
102 102 }
103 103 scope :like, lambda {|arg|
104 104 if arg.blank?
105 105 {}
106 106 else
107 107 pattern = "%#{arg.to_s.strip.downcase}%"
108 108 {:conditions => ["LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", {:p => pattern}]}
109 109 end
110 110 }
111 111
112 112 def initialize(attributes=nil, *args)
113 113 super
114 114
115 115 initialized = (attributes || {}).stringify_keys
116 116 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
117 117 self.identifier = Project.next_identifier
118 118 end
119 119 if !initialized.key?('is_public')
120 120 self.is_public = Setting.default_projects_public?
121 121 end
122 122 if !initialized.key?('enabled_module_names')
123 123 self.enabled_module_names = Setting.default_projects_modules
124 124 end
125 125 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
126 126 self.trackers = Tracker.sorted.all
127 127 end
128 128 end
129 129
130 130 def identifier=(identifier)
131 131 super unless identifier_frozen?
132 132 end
133 133
134 134 def identifier_frozen?
135 135 errors[:identifier].blank? && !(new_record? || identifier.blank?)
136 136 end
137 137
138 138 # returns latest created projects
139 139 # non public projects will be returned only if user is a member of those
140 140 def self.latest(user=nil, count=5)
141 visible(user).find(:all, :limit => count, :order => "created_on DESC")
141 visible(user).limit(count).order("created_on DESC").all
142 142 end
143 143
144 144 # Returns true if the project is visible to +user+ or to the current user.
145 145 def visible?(user=User.current)
146 146 user.allowed_to?(:view_project, self)
147 147 end
148 148
149 149 # Returns a SQL conditions string used to find all projects visible by the specified user.
150 150 #
151 151 # Examples:
152 152 # Project.visible_condition(admin) => "projects.status = 1"
153 153 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
154 154 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
155 155 def self.visible_condition(user, options={})
156 156 allowed_to_condition(user, :view_project, options)
157 157 end
158 158
159 159 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
160 160 #
161 161 # Valid options:
162 162 # * :project => limit the condition to project
163 163 # * :with_subprojects => limit the condition to project and its subprojects
164 164 # * :member => limit the condition to the user projects
165 165 def self.allowed_to_condition(user, permission, options={})
166 166 perm = Redmine::AccessControl.permission(permission)
167 167 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
168 168 if perm && perm.project_module
169 169 # If the permission belongs to a project module, make sure the module is enabled
170 170 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
171 171 end
172 172 if options[:project]
173 173 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
174 174 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
175 175 base_statement = "(#{project_statement}) AND (#{base_statement})"
176 176 end
177 177
178 178 if user.admin?
179 179 base_statement
180 180 else
181 181 statement_by_role = {}
182 182 unless options[:member]
183 183 role = user.logged? ? Role.non_member : Role.anonymous
184 184 if role.allowed_to?(permission)
185 185 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
186 186 end
187 187 end
188 188 if user.logged?
189 189 user.projects_by_role.each do |role, projects|
190 190 if role.allowed_to?(permission) && projects.any?
191 191 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
192 192 end
193 193 end
194 194 end
195 195 if statement_by_role.empty?
196 196 "1=0"
197 197 else
198 198 if block_given?
199 199 statement_by_role.each do |role, statement|
200 200 if s = yield(role, user)
201 201 statement_by_role[role] = "(#{statement} AND (#{s}))"
202 202 end
203 203 end
204 204 end
205 205 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
206 206 end
207 207 end
208 208 end
209 209
210 210 # Returns the Systemwide and project specific activities
211 211 def activities(include_inactive=false)
212 212 if include_inactive
213 213 return all_activities
214 214 else
215 215 return active_activities
216 216 end
217 217 end
218 218
219 219 # Will create a new Project specific Activity or update an existing one
220 220 #
221 221 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
222 222 # does not successfully save.
223 223 def update_or_create_time_entry_activity(id, activity_hash)
224 224 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
225 225 self.create_time_entry_activity_if_needed(activity_hash)
226 226 else
227 227 activity = project.time_entry_activities.find_by_id(id.to_i)
228 228 activity.update_attributes(activity_hash) if activity
229 229 end
230 230 end
231 231
232 232 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
233 233 #
234 234 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
235 235 # does not successfully save.
236 236 def create_time_entry_activity_if_needed(activity)
237 237 if activity['parent_id']
238 238
239 239 parent_activity = TimeEntryActivity.find(activity['parent_id'])
240 240 activity['name'] = parent_activity.name
241 241 activity['position'] = parent_activity.position
242 242
243 243 if Enumeration.overridding_change?(activity, parent_activity)
244 244 project_activity = self.time_entry_activities.create(activity)
245 245
246 246 if project_activity.new_record?
247 247 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
248 248 else
249 249 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
250 250 end
251 251 end
252 252 end
253 253 end
254 254
255 255 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
256 256 #
257 257 # Examples:
258 258 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
259 259 # project.project_condition(false) => "projects.id = 1"
260 260 def project_condition(with_subprojects)
261 261 cond = "#{Project.table_name}.id = #{id}"
262 262 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
263 263 cond
264 264 end
265 265
266 266 def self.find(*args)
267 267 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
268 268 project = find_by_identifier(*args)
269 269 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
270 270 project
271 271 else
272 272 super
273 273 end
274 274 end
275 275
276 276 def self.find_by_param(*args)
277 277 self.find(*args)
278 278 end
279 279
280 280 def reload(*args)
281 281 @shared_versions = nil
282 282 @rolled_up_versions = nil
283 283 @rolled_up_trackers = nil
284 284 @all_issue_custom_fields = nil
285 285 @all_time_entry_custom_fields = nil
286 286 @to_param = nil
287 287 @allowed_parents = nil
288 288 @allowed_permissions = nil
289 289 @actions_allowed = nil
290 290 super
291 291 end
292 292
293 293 def to_param
294 294 # id is used for projects with a numeric identifier (compatibility)
295 295 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
296 296 end
297 297
298 298 def active?
299 299 self.status == STATUS_ACTIVE
300 300 end
301 301
302 302 def archived?
303 303 self.status == STATUS_ARCHIVED
304 304 end
305 305
306 306 # Archives the project and its descendants
307 307 def archive
308 308 # Check that there is no issue of a non descendant project that is assigned
309 309 # to one of the project or descendant versions
310 310 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
311 311 if v_ids.any? && Issue.find(:first, :include => :project,
312 312 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
313 313 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
314 314 return false
315 315 end
316 316 Project.transaction do
317 317 archive!
318 318 end
319 319 true
320 320 end
321 321
322 322 # Unarchives the project
323 323 # All its ancestors must be active
324 324 def unarchive
325 325 return false if ancestors.detect {|a| !a.active?}
326 326 update_attribute :status, STATUS_ACTIVE
327 327 end
328 328
329 329 def close
330 330 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
331 331 end
332 332
333 333 def reopen
334 334 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
335 335 end
336 336
337 337 # Returns an array of projects the project can be moved to
338 338 # by the current user
339 339 def allowed_parents
340 340 return @allowed_parents if @allowed_parents
341 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
341 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
342 342 @allowed_parents = @allowed_parents - self_and_descendants
343 343 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
344 344 @allowed_parents << nil
345 345 end
346 346 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
347 347 @allowed_parents << parent
348 348 end
349 349 @allowed_parents
350 350 end
351 351
352 352 # Sets the parent of the project with authorization check
353 353 def set_allowed_parent!(p)
354 354 unless p.nil? || p.is_a?(Project)
355 355 if p.to_s.blank?
356 356 p = nil
357 357 else
358 358 p = Project.find_by_id(p)
359 359 return false unless p
360 360 end
361 361 end
362 362 if p.nil?
363 363 if !new_record? && allowed_parents.empty?
364 364 return false
365 365 end
366 366 elsif !allowed_parents.include?(p)
367 367 return false
368 368 end
369 369 set_parent!(p)
370 370 end
371 371
372 372 # Sets the parent of the project
373 373 # Argument can be either a Project, a String, a Fixnum or nil
374 374 def set_parent!(p)
375 375 unless p.nil? || p.is_a?(Project)
376 376 if p.to_s.blank?
377 377 p = nil
378 378 else
379 379 p = Project.find_by_id(p)
380 380 return false unless p
381 381 end
382 382 end
383 383 if p == parent && !p.nil?
384 384 # Nothing to do
385 385 true
386 386 elsif p.nil? || (p.active? && move_possible?(p))
387 387 set_or_update_position_under(p)
388 388 Issue.update_versions_from_hierarchy_change(self)
389 389 true
390 390 else
391 391 # Can not move to the given target
392 392 false
393 393 end
394 394 end
395 395
396 396 # Recalculates all lft and rgt values based on project names
397 397 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
398 398 # Used in BuildProjectsTree migration
399 399 def self.rebuild_tree!
400 400 transaction do
401 401 update_all "lft = NULL, rgt = NULL"
402 402 rebuild!(false)
403 403 end
404 404 end
405 405
406 406 # Returns an array of the trackers used by the project and its active sub projects
407 407 def rolled_up_trackers
408 408 @rolled_up_trackers ||=
409 409 Tracker.find(:all, :joins => :projects,
410 410 :select => "DISTINCT #{Tracker.table_name}.*",
411 411 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt],
412 412 :order => "#{Tracker.table_name}.position")
413 413 end
414 414
415 415 # Closes open and locked project versions that are completed
416 416 def close_completed_versions
417 417 Version.transaction do
418 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
418 versions.where(:status => %w(open locked)).all.each do |version|
419 419 if version.completed?
420 420 version.update_attribute(:status, 'closed')
421 421 end
422 422 end
423 423 end
424 424 end
425 425
426 426 # Returns a scope of the Versions on subprojects
427 427 def rolled_up_versions
428 428 @rolled_up_versions ||=
429 429 Version.scoped(:include => :project,
430 430 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
431 431 end
432 432
433 433 # Returns a scope of the Versions used by the project
434 434 def shared_versions
435 435 if new_record?
436 436 Version.scoped(:include => :project,
437 437 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
438 438 else
439 439 @shared_versions ||= begin
440 440 r = root? ? self : root
441 441 Version.scoped(:include => :project,
442 442 :conditions => "#{Project.table_name}.id = #{id}" +
443 443 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
444 444 " #{Version.table_name}.sharing = 'system'" +
445 445 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
446 446 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
447 447 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
448 448 "))")
449 449 end
450 450 end
451 451 end
452 452
453 453 # Returns a hash of project users grouped by role
454 454 def users_by_role
455 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
455 members.includes(:user, :roles).all.inject({}) do |h, m|
456 456 m.roles.each do |r|
457 457 h[r] ||= []
458 458 h[r] << m.user
459 459 end
460 460 h
461 461 end
462 462 end
463 463
464 464 # Deletes all project's members
465 465 def delete_all_members
466 466 me, mr = Member.table_name, MemberRole.table_name
467 467 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
468 468 Member.delete_all(['project_id = ?', id])
469 469 end
470 470
471 471 # Users/groups issues can be assigned to
472 472 def assignable_users
473 473 assignable = Setting.issue_group_assignment? ? member_principals : members
474 474 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
475 475 end
476 476
477 477 # Returns the mail adresses of users that should be always notified on project events
478 478 def recipients
479 479 notified_users.collect {|user| user.mail}
480 480 end
481 481
482 482 # Returns the users that should be notified on project events
483 483 def notified_users
484 484 # TODO: User part should be extracted to User#notify_about?
485 485 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
486 486 end
487 487
488 488 # Returns an array of all custom fields enabled for project issues
489 489 # (explictly associated custom fields and custom fields enabled for all projects)
490 490 def all_issue_custom_fields
491 491 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
492 492 end
493 493
494 494 # Returns an array of all custom fields enabled for project time entries
495 495 # (explictly associated custom fields and custom fields enabled for all projects)
496 496 def all_time_entry_custom_fields
497 497 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
498 498 end
499 499
500 500 def project
501 501 self
502 502 end
503 503
504 504 def <=>(project)
505 505 name.downcase <=> project.name.downcase
506 506 end
507 507
508 508 def to_s
509 509 name
510 510 end
511 511
512 512 # Returns a short description of the projects (first lines)
513 513 def short_description(length = 255)
514 514 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
515 515 end
516 516
517 517 def css_classes
518 518 s = 'project'
519 519 s << ' root' if root?
520 520 s << ' child' if child?
521 521 s << (leaf? ? ' leaf' : ' parent')
522 522 unless active?
523 523 if archived?
524 524 s << ' archived'
525 525 else
526 526 s << ' closed'
527 527 end
528 528 end
529 529 s
530 530 end
531 531
532 532 # The earliest start date of a project, based on it's issues and versions
533 533 def start_date
534 534 [
535 535 issues.minimum('start_date'),
536 536 shared_versions.collect(&:effective_date),
537 537 shared_versions.collect(&:start_date)
538 538 ].flatten.compact.min
539 539 end
540 540
541 541 # The latest due date of an issue or version
542 542 def due_date
543 543 [
544 544 issues.maximum('due_date'),
545 545 shared_versions.collect(&:effective_date),
546 546 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
547 547 ].flatten.compact.max
548 548 end
549 549
550 550 def overdue?
551 551 active? && !due_date.nil? && (due_date < Date.today)
552 552 end
553 553
554 554 # Returns the percent completed for this project, based on the
555 555 # progress on it's versions.
556 556 def completed_percent(options={:include_subprojects => false})
557 557 if options.delete(:include_subprojects)
558 558 total = self_and_descendants.collect(&:completed_percent).sum
559 559
560 560 total / self_and_descendants.count
561 561 else
562 562 if versions.count > 0
563 563 total = versions.collect(&:completed_pourcent).sum
564 564
565 565 total / versions.count
566 566 else
567 567 100
568 568 end
569 569 end
570 570 end
571 571
572 572 # Return true if this project allows to do the specified action.
573 573 # action can be:
574 574 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
575 575 # * a permission Symbol (eg. :edit_project)
576 576 def allows_to?(action)
577 577 if archived?
578 578 # No action allowed on archived projects
579 579 return false
580 580 end
581 581 unless active? || Redmine::AccessControl.read_action?(action)
582 582 # No write action allowed on closed projects
583 583 return false
584 584 end
585 585 # No action allowed on disabled modules
586 586 if action.is_a? Hash
587 587 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
588 588 else
589 589 allowed_permissions.include? action
590 590 end
591 591 end
592 592
593 593 def module_enabled?(module_name)
594 594 module_name = module_name.to_s
595 595 enabled_modules.detect {|m| m.name == module_name}
596 596 end
597 597
598 598 def enabled_module_names=(module_names)
599 599 if module_names && module_names.is_a?(Array)
600 600 module_names = module_names.collect(&:to_s).reject(&:blank?)
601 601 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
602 602 else
603 603 enabled_modules.clear
604 604 end
605 605 end
606 606
607 607 # Returns an array of the enabled modules names
608 608 def enabled_module_names
609 609 enabled_modules.collect(&:name)
610 610 end
611 611
612 612 # Enable a specific module
613 613 #
614 614 # Examples:
615 615 # project.enable_module!(:issue_tracking)
616 616 # project.enable_module!("issue_tracking")
617 617 def enable_module!(name)
618 618 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
619 619 end
620 620
621 621 # Disable a module if it exists
622 622 #
623 623 # Examples:
624 624 # project.disable_module!(:issue_tracking)
625 625 # project.disable_module!("issue_tracking")
626 626 # project.disable_module!(project.enabled_modules.first)
627 627 def disable_module!(target)
628 628 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
629 629 target.destroy unless target.blank?
630 630 end
631 631
632 632 safe_attributes 'name',
633 633 'description',
634 634 'homepage',
635 635 'is_public',
636 636 'identifier',
637 637 'custom_field_values',
638 638 'custom_fields',
639 639 'tracker_ids',
640 640 'issue_custom_field_ids'
641 641
642 642 safe_attributes 'enabled_module_names',
643 643 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
644 644
645 645 # Returns an array of projects that are in this project's hierarchy
646 646 #
647 647 # Example: parents, children, siblings
648 648 def hierarchy
649 649 parents = project.self_and_ancestors || []
650 650 descendants = project.descendants || []
651 651 project_hierarchy = parents | descendants # Set union
652 652 end
653 653
654 654 # Returns an auto-generated project identifier based on the last identifier used
655 655 def self.next_identifier
656 656 p = Project.find(:first, :order => 'created_on DESC')
657 657 p.nil? ? nil : p.identifier.to_s.succ
658 658 end
659 659
660 660 # Copies and saves the Project instance based on the +project+.
661 661 # Duplicates the source project's:
662 662 # * Wiki
663 663 # * Versions
664 664 # * Categories
665 665 # * Issues
666 666 # * Members
667 667 # * Queries
668 668 #
669 669 # Accepts an +options+ argument to specify what to copy
670 670 #
671 671 # Examples:
672 672 # project.copy(1) # => copies everything
673 673 # project.copy(1, :only => 'members') # => copies members only
674 674 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
675 675 def copy(project, options={})
676 676 project = project.is_a?(Project) ? project : Project.find(project)
677 677
678 678 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
679 679 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
680 680
681 681 Project.transaction do
682 682 if save
683 683 reload
684 684 to_be_copied.each do |name|
685 685 send "copy_#{name}", project
686 686 end
687 687 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
688 688 save
689 689 end
690 690 end
691 691 end
692 692
693 693
694 694 # Copies +project+ and returns the new instance. This will not save
695 695 # the copy
696 696 def self.copy_from(project)
697 697 begin
698 698 project = project.is_a?(Project) ? project : Project.find(project)
699 699 if project
700 700 # clear unique attributes
701 701 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
702 702 copy = Project.new(attributes)
703 703 copy.enabled_modules = project.enabled_modules
704 704 copy.trackers = project.trackers
705 705 copy.custom_values = project.custom_values.collect {|v| v.clone}
706 706 copy.issue_custom_fields = project.issue_custom_fields
707 707 return copy
708 708 else
709 709 return nil
710 710 end
711 711 rescue ActiveRecord::RecordNotFound
712 712 return nil
713 713 end
714 714 end
715 715
716 716 # Yields the given block for each project with its level in the tree
717 717 def self.project_tree(projects, &block)
718 718 ancestors = []
719 719 projects.sort_by(&:lft).each do |project|
720 720 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
721 721 ancestors.pop
722 722 end
723 723 yield project, ancestors.size
724 724 ancestors << project
725 725 end
726 726 end
727 727
728 728 private
729 729
730 730 # Copies wiki from +project+
731 731 def copy_wiki(project)
732 732 # Check that the source project has a wiki first
733 733 unless project.wiki.nil?
734 734 self.wiki ||= Wiki.new
735 735 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
736 736 wiki_pages_map = {}
737 737 project.wiki.pages.each do |page|
738 738 # Skip pages without content
739 739 next if page.content.nil?
740 740 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
741 741 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
742 742 new_wiki_page.content = new_wiki_content
743 743 wiki.pages << new_wiki_page
744 744 wiki_pages_map[page.id] = new_wiki_page
745 745 end
746 746 wiki.save
747 747 # Reproduce page hierarchy
748 748 project.wiki.pages.each do |page|
749 749 if page.parent_id && wiki_pages_map[page.id]
750 750 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
751 751 wiki_pages_map[page.id].save
752 752 end
753 753 end
754 754 end
755 755 end
756 756
757 757 # Copies versions from +project+
758 758 def copy_versions(project)
759 759 project.versions.each do |version|
760 760 new_version = Version.new
761 761 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
762 762 self.versions << new_version
763 763 end
764 764 end
765 765
766 766 # Copies issue categories from +project+
767 767 def copy_issue_categories(project)
768 768 project.issue_categories.each do |issue_category|
769 769 new_issue_category = IssueCategory.new
770 770 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
771 771 self.issue_categories << new_issue_category
772 772 end
773 773 end
774 774
775 775 # Copies issues from +project+
776 776 def copy_issues(project)
777 777 # Stores the source issue id as a key and the copied issues as the
778 778 # value. Used to map the two togeather for issue relations.
779 779 issues_map = {}
780 780
781 781 # Store status and reopen locked/closed versions
782 782 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
783 783 version_statuses.each do |version, status|
784 784 version.update_attribute :status, 'open'
785 785 end
786 786
787 787 # Get issues sorted by root_id, lft so that parent issues
788 788 # get copied before their children
789 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
789 project.issues.reorder('root_id, lft').all.each do |issue|
790 790 new_issue = Issue.new
791 791 new_issue.copy_from(issue, :subtasks => false, :link => false)
792 792 new_issue.project = self
793 793 # Reassign fixed_versions by name, since names are unique per project
794 794 if issue.fixed_version && issue.fixed_version.project == project
795 795 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
796 796 end
797 797 # Reassign the category by name, since names are unique per project
798 798 if issue.category
799 799 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
800 800 end
801 801 # Parent issue
802 802 if issue.parent_id
803 803 if copied_parent = issues_map[issue.parent_id]
804 804 new_issue.parent_issue_id = copied_parent.id
805 805 end
806 806 end
807 807
808 808 self.issues << new_issue
809 809 if new_issue.new_record?
810 810 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
811 811 else
812 812 issues_map[issue.id] = new_issue unless new_issue.new_record?
813 813 end
814 814 end
815 815
816 816 # Restore locked/closed version statuses
817 817 version_statuses.each do |version, status|
818 818 version.update_attribute :status, status
819 819 end
820 820
821 821 # Relations after in case issues related each other
822 822 project.issues.each do |issue|
823 823 new_issue = issues_map[issue.id]
824 824 unless new_issue
825 825 # Issue was not copied
826 826 next
827 827 end
828 828
829 829 # Relations
830 830 issue.relations_from.each do |source_relation|
831 831 new_issue_relation = IssueRelation.new
832 832 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
833 833 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
834 834 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
835 835 new_issue_relation.issue_to = source_relation.issue_to
836 836 end
837 837 new_issue.relations_from << new_issue_relation
838 838 end
839 839
840 840 issue.relations_to.each do |source_relation|
841 841 new_issue_relation = IssueRelation.new
842 842 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
843 843 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
844 844 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
845 845 new_issue_relation.issue_from = source_relation.issue_from
846 846 end
847 847 new_issue.relations_to << new_issue_relation
848 848 end
849 849 end
850 850 end
851 851
852 852 # Copies members from +project+
853 853 def copy_members(project)
854 854 # Copy users first, then groups to handle members with inherited and given roles
855 855 members_to_copy = []
856 856 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
857 857 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
858 858
859 859 members_to_copy.each do |member|
860 860 new_member = Member.new
861 861 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
862 862 # only copy non inherited roles
863 863 # inherited roles will be added when copying the group membership
864 864 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
865 865 next if role_ids.empty?
866 866 new_member.role_ids = role_ids
867 867 new_member.project = self
868 868 self.members << new_member
869 869 end
870 870 end
871 871
872 872 # Copies queries from +project+
873 873 def copy_queries(project)
874 874 project.queries.each do |query|
875 875 new_query = ::Query.new
876 876 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
877 877 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
878 878 new_query.project = self
879 879 new_query.user_id = query.user_id
880 880 self.queries << new_query
881 881 end
882 882 end
883 883
884 884 # Copies boards from +project+
885 885 def copy_boards(project)
886 886 project.boards.each do |board|
887 887 new_board = Board.new
888 888 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
889 889 new_board.project = self
890 890 self.boards << new_board
891 891 end
892 892 end
893 893
894 894 def allowed_permissions
895 895 @allowed_permissions ||= begin
896 896 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
897 897 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
898 898 end
899 899 end
900 900
901 901 def allowed_actions
902 902 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
903 903 end
904 904
905 905 # Returns all the active Systemwide and project specific activities
906 906 def active_activities
907 907 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
908 908
909 909 if overridden_activity_ids.empty?
910 910 return TimeEntryActivity.shared.active
911 911 else
912 912 return system_activities_and_project_overrides
913 913 end
914 914 end
915 915
916 916 # Returns all the Systemwide and project specific activities
917 917 # (inactive and active)
918 918 def all_activities
919 919 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
920 920
921 921 if overridden_activity_ids.empty?
922 922 return TimeEntryActivity.shared
923 923 else
924 924 return system_activities_and_project_overrides(true)
925 925 end
926 926 end
927 927
928 928 # Returns the systemwide active activities merged with the project specific overrides
929 929 def system_activities_and_project_overrides(include_inactive=false)
930 930 if include_inactive
931 931 return TimeEntryActivity.shared.
932 find(:all,
933 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
932 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
934 933 self.time_entry_activities
935 934 else
936 935 return TimeEntryActivity.shared.active.
937 find(:all,
938 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
936 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
939 937 self.time_entry_activities.active
940 938 end
941 939 end
942 940
943 941 # Archives subprojects recursively
944 942 def archive!
945 943 children.each do |subproject|
946 944 subproject.send :archive!
947 945 end
948 946 update_attribute :status, STATUS_ARCHIVED
949 947 end
950 948
951 949 def update_position_under_parent
952 950 set_or_update_position_under(parent)
953 951 end
954 952
955 953 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
956 954 def set_or_update_position_under(target_parent)
957 955 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
958 956 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
959 957
960 958 if to_be_inserted_before
961 959 move_to_left_of(to_be_inserted_before)
962 960 elsif target_parent.nil?
963 961 if sibs.empty?
964 962 # move_to_root adds the project in first (ie. left) position
965 963 move_to_root
966 964 else
967 965 move_to_right_of(sibs.last) unless self == sibs.last
968 966 end
969 967 else
970 968 # move_to_child_of adds the project in last (ie.right) position
971 969 move_to_child_of(target_parent)
972 970 end
973 971 end
974 972 end
@@ -1,1084 +1,1079
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.default_order = options[:default_order]
30 30 @caption_key = options[:caption] || "field_#{name}"
31 31 end
32 32
33 33 def caption
34 34 l(@caption_key)
35 35 end
36 36
37 37 # Returns true if the column is sortable, otherwise false
38 38 def sortable?
39 39 !@sortable.nil?
40 40 end
41 41
42 42 def sortable
43 43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 44 end
45 45
46 46 def value(issue)
47 47 issue.send name
48 48 end
49 49
50 50 def css_classes
51 51 name
52 52 end
53 53 end
54 54
55 55 class QueryCustomFieldColumn < QueryColumn
56 56
57 57 def initialize(custom_field)
58 58 self.name = "cf_#{custom_field.id}".to_sym
59 59 self.sortable = custom_field.order_statement || false
60 60 self.groupable = custom_field.group_statement || false
61 61 @cf = custom_field
62 62 end
63 63
64 64 def caption
65 65 @cf.name
66 66 end
67 67
68 68 def custom_field
69 69 @cf
70 70 end
71 71
72 72 def value(issue)
73 73 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
74 74 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
75 75 end
76 76
77 77 def css_classes
78 78 @css_classes ||= "#{name} #{@cf.field_format}"
79 79 end
80 80 end
81 81
82 82 class Query < ActiveRecord::Base
83 83 class StatementInvalid < ::ActiveRecord::StatementInvalid
84 84 end
85 85
86 86 belongs_to :project
87 87 belongs_to :user
88 88 serialize :filters
89 89 serialize :column_names
90 90 serialize :sort_criteria, Array
91 91
92 92 attr_protected :project_id, :user_id
93 93
94 94 validates_presence_of :name
95 95 validates_length_of :name, :maximum => 255
96 96 validate :validate_query_filters
97 97
98 98 @@operators = { "=" => :label_equals,
99 99 "!" => :label_not_equals,
100 100 "o" => :label_open_issues,
101 101 "c" => :label_closed_issues,
102 102 "!*" => :label_none,
103 103 "*" => :label_any,
104 104 ">=" => :label_greater_or_equal,
105 105 "<=" => :label_less_or_equal,
106 106 "><" => :label_between,
107 107 "<t+" => :label_in_less_than,
108 108 ">t+" => :label_in_more_than,
109 109 "><t+"=> :label_in_the_next_days,
110 110 "t+" => :label_in,
111 111 "t" => :label_today,
112 112 "w" => :label_this_week,
113 113 ">t-" => :label_less_than_ago,
114 114 "<t-" => :label_more_than_ago,
115 115 "><t-"=> :label_in_the_past_days,
116 116 "t-" => :label_ago,
117 117 "~" => :label_contains,
118 118 "!~" => :label_not_contains,
119 119 "=p" => :label_any_issues_in_project,
120 120 "=!p" => :label_any_issues_not_in_project,
121 121 "!p" => :label_no_issues_in_project}
122 122
123 123 cattr_reader :operators
124 124
125 125 @@operators_by_filter_type = { :list => [ "=", "!" ],
126 126 :list_status => [ "o", "=", "!", "c", "*" ],
127 127 :list_optional => [ "=", "!", "!*", "*" ],
128 128 :list_subprojects => [ "*", "!*", "=" ],
129 129 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "w", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
130 130 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "w", "!*", "*" ],
131 131 :string => [ "=", "~", "!", "!~", "!*", "*" ],
132 132 :text => [ "~", "!~", "!*", "*" ],
133 133 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
134 134 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
135 135 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]}
136 136
137 137 cattr_reader :operators_by_filter_type
138 138
139 139 @@available_columns = [
140 140 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
141 141 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
142 142 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
143 143 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
144 144 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
145 145 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
146 146 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
147 147 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
148 148 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
149 149 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
150 150 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
151 151 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
152 152 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
153 153 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
154 154 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
155 155 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
156 156 QueryColumn.new(:relations, :caption => :label_related_issues)
157 157 ]
158 158 cattr_reader :available_columns
159 159
160 160 scope :visible, lambda {|*args|
161 161 user = args.shift || User.current
162 162 base = Project.allowed_to_condition(user, :view_issues, *args)
163 163 user_id = user.logged? ? user.id : 0
164 164 {
165 165 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
166 166 :include => :project
167 167 }
168 168 }
169 169
170 170 def initialize(attributes=nil, *args)
171 171 super attributes
172 172 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
173 173 @is_for_all = project.nil?
174 174 end
175 175
176 176 def validate_query_filters
177 177 filters.each_key do |field|
178 178 if values_for(field)
179 179 case type_for(field)
180 180 when :integer
181 181 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
182 182 when :float
183 183 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
184 184 when :date, :date_past
185 185 case operator_for(field)
186 186 when "=", ">=", "<=", "><"
187 187 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
188 188 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
189 189 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
190 190 end
191 191 end
192 192 end
193 193
194 194 add_filter_error(field, :blank) unless
195 195 # filter requires one or more values
196 196 (values_for(field) and !values_for(field).first.blank?) or
197 197 # filter doesn't require any value
198 198 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
199 199 end if filters
200 200 end
201 201
202 202 def add_filter_error(field, message)
203 203 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
204 204 errors.add(:base, m)
205 205 end
206 206
207 207 # Returns true if the query is visible to +user+ or the current user.
208 208 def visible?(user=User.current)
209 209 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
210 210 end
211 211
212 212 def editable_by?(user)
213 213 return false unless user
214 214 # Admin can edit them all and regular users can edit their private queries
215 215 return true if user.admin? || (!is_public && self.user_id == user.id)
216 216 # Members can not edit public queries that are for all project (only admin is allowed to)
217 217 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
218 218 end
219 219
220 220 def trackers
221 @trackers ||= project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
221 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
222 222 end
223 223
224 224 # Returns a hash of localized labels for all filter operators
225 225 def self.operators_labels
226 226 operators.inject({}) {|h, operator| h[operator.first] = l(operator.last); h}
227 227 end
228 228
229 229 def available_filters
230 230 return @available_filters if @available_filters
231 231 @available_filters = {
232 232 "status_id" => {
233 233 :type => :list_status, :order => 0,
234 :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] }
234 :values => IssueStatus.sorted.all.collect{|s| [s.name, s.id.to_s] }
235 235 },
236 236 "tracker_id" => {
237 237 :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] }
238 238 },
239 239 "priority_id" => {
240 240 :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
241 241 },
242 242 "subject" => { :type => :text, :order => 8 },
243 243 "created_on" => { :type => :date_past, :order => 9 },
244 244 "updated_on" => { :type => :date_past, :order => 10 },
245 245 "start_date" => { :type => :date, :order => 11 },
246 246 "due_date" => { :type => :date, :order => 12 },
247 247 "estimated_hours" => { :type => :float, :order => 13 },
248 248 "done_ratio" => { :type => :integer, :order => 14 }
249 249 }
250 250 IssueRelation::TYPES.each do |relation_type, options|
251 251 @available_filters[relation_type] = {
252 252 :type => :relation, :order => @available_filters.size + 100,
253 253 :label => options[:name]
254 254 }
255 255 end
256 256 principals = []
257 257 if project
258 258 principals += project.principals.sort
259 259 unless project.leaf?
260 260 subprojects = project.descendants.visible.all
261 261 if subprojects.any?
262 262 @available_filters["subproject_id"] = {
263 263 :type => :list_subprojects, :order => 13,
264 264 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
265 265 }
266 266 principals += Principal.member_of(subprojects)
267 267 end
268 268 end
269 269 else
270 270 if all_projects.any?
271 271 # members of visible projects
272 272 principals += Principal.member_of(all_projects)
273 273 # project filter
274 274 project_values = []
275 275 if User.current.logged? && User.current.memberships.any?
276 276 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
277 277 end
278 278 project_values += all_projects_values
279 279 @available_filters["project_id"] = {
280 280 :type => :list, :order => 1, :values => project_values
281 281 } unless project_values.empty?
282 282 end
283 283 end
284 284 principals.uniq!
285 285 principals.sort!
286 286 users = principals.select {|p| p.is_a?(User)}
287 287
288 288 assigned_to_values = []
289 289 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
290 290 assigned_to_values += (Setting.issue_group_assignment? ?
291 291 principals : users).collect{|s| [s.name, s.id.to_s] }
292 292 @available_filters["assigned_to_id"] = {
293 293 :type => :list_optional, :order => 4, :values => assigned_to_values
294 294 } unless assigned_to_values.empty?
295 295
296 296 author_values = []
297 297 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
298 298 author_values += users.collect{|s| [s.name, s.id.to_s] }
299 299 @available_filters["author_id"] = {
300 300 :type => :list, :order => 5, :values => author_values
301 301 } unless author_values.empty?
302 302
303 303 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
304 304 @available_filters["member_of_group"] = {
305 305 :type => :list_optional, :order => 6, :values => group_values
306 306 } unless group_values.empty?
307 307
308 308 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
309 309 @available_filters["assigned_to_role"] = {
310 310 :type => :list_optional, :order => 7, :values => role_values
311 311 } unless role_values.empty?
312 312
313 313 if User.current.logged?
314 314 @available_filters["watcher_id"] = {
315 315 :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]]
316 316 }
317 317 end
318 318
319 319 if project
320 320 # project specific filters
321 321 categories = project.issue_categories.all
322 322 unless categories.empty?
323 323 @available_filters["category_id"] = {
324 324 :type => :list_optional, :order => 6,
325 325 :values => categories.collect{|s| [s.name, s.id.to_s] }
326 326 }
327 327 end
328 328 versions = project.shared_versions.all
329 329 unless versions.empty?
330 330 @available_filters["fixed_version_id"] = {
331 331 :type => :list_optional, :order => 7,
332 332 :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
333 333 }
334 334 end
335 335 add_custom_fields_filters(project.all_issue_custom_fields)
336 336 else
337 337 # global filters for cross project issue list
338 338 system_shared_versions = Version.visible.find_all_by_sharing('system')
339 339 unless system_shared_versions.empty?
340 340 @available_filters["fixed_version_id"] = {
341 341 :type => :list_optional, :order => 7,
342 342 :values => system_shared_versions.sort.collect{|s|
343 343 ["#{s.project.name} - #{s.name}", s.id.to_s]
344 344 }
345 345 }
346 346 end
347 add_custom_fields_filters(
348 IssueCustomField.find(:all,
349 :conditions => {
350 :is_filter => true,
351 :is_for_all => true
352 }))
347 add_custom_fields_filters(IssueCustomField.where(:is_filter => true, :is_for_all => true).all)
353 348 end
354 349 add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
355 350 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
356 351 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
357 352 @available_filters["is_private"] = {
358 353 :type => :list, :order => 16,
359 354 :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
360 355 }
361 356 end
362 357 Tracker.disabled_core_fields(trackers).each {|field|
363 358 @available_filters.delete field
364 359 }
365 360 @available_filters.each do |field, options|
366 361 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
367 362 end
368 363 @available_filters
369 364 end
370 365
371 366 # Returns a representation of the available filters for JSON serialization
372 367 def available_filters_as_json
373 368 json = {}
374 369 available_filters.each do |field, options|
375 370 json[field] = options.slice(:type, :name, :values).stringify_keys
376 371 end
377 372 json
378 373 end
379 374
380 375 def all_projects
381 376 @all_projects ||= Project.visible.all
382 377 end
383 378
384 379 def all_projects_values
385 380 return @all_projects_values if @all_projects_values
386 381
387 382 values = []
388 383 Project.project_tree(all_projects) do |p, level|
389 384 prefix = (level > 0 ? ('--' * level + ' ') : '')
390 385 values << ["#{prefix}#{p.name}", p.id.to_s]
391 386 end
392 387 @all_projects_values = values
393 388 end
394 389
395 390 def add_filter(field, operator, values)
396 391 # values must be an array
397 392 return unless values.nil? || values.is_a?(Array)
398 393 # check if field is defined as an available filter
399 394 if available_filters.has_key? field
400 395 filter_options = available_filters[field]
401 396 # check if operator is allowed for that filter
402 397 #if @@operators_by_filter_type[filter_options[:type]].include? operator
403 398 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
404 399 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
405 400 #end
406 401 filters[field] = {:operator => operator, :values => (values || [''])}
407 402 end
408 403 end
409 404
410 405 def add_short_filter(field, expression)
411 406 return unless expression && available_filters.has_key?(field)
412 407 field_type = available_filters[field][:type]
413 408 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
414 409 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
415 410 add_filter field, operator, $1.present? ? $1.split('|') : ['']
416 411 end || add_filter(field, '=', expression.split('|'))
417 412 end
418 413
419 414 # Add multiple filters using +add_filter+
420 415 def add_filters(fields, operators, values)
421 416 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
422 417 fields.each do |field|
423 418 add_filter(field, operators[field], values && values[field])
424 419 end
425 420 end
426 421 end
427 422
428 423 def has_filter?(field)
429 424 filters and filters[field]
430 425 end
431 426
432 427 def type_for(field)
433 428 available_filters[field][:type] if available_filters.has_key?(field)
434 429 end
435 430
436 431 def operator_for(field)
437 432 has_filter?(field) ? filters[field][:operator] : nil
438 433 end
439 434
440 435 def values_for(field)
441 436 has_filter?(field) ? filters[field][:values] : nil
442 437 end
443 438
444 439 def value_for(field, index=0)
445 440 (values_for(field) || [])[index]
446 441 end
447 442
448 443 def label_for(field)
449 444 label = available_filters[field][:name] if available_filters.has_key?(field)
450 445 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
451 446 end
452 447
453 448 def available_columns
454 449 return @available_columns if @available_columns
455 450 @available_columns = ::Query.available_columns.dup
456 451 @available_columns += (project ?
457 452 project.all_issue_custom_fields :
458 IssueCustomField.find(:all)
453 IssueCustomField.all
459 454 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
460 455
461 456 if User.current.allowed_to?(:view_time_entries, project, :global => true)
462 457 index = nil
463 458 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
464 459 index = (index ? index + 1 : -1)
465 460 # insert the column after estimated_hours or at the end
466 461 @available_columns.insert index, QueryColumn.new(:spent_hours,
467 462 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
468 463 :default_order => 'desc',
469 464 :caption => :label_spent_time
470 465 )
471 466 end
472 467
473 468 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
474 469 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
475 470 @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
476 471 end
477 472
478 473 disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
479 474 @available_columns.reject! {|column|
480 475 disabled_fields.include?(column.name.to_s)
481 476 }
482 477
483 478 @available_columns
484 479 end
485 480
486 481 def self.available_columns=(v)
487 482 self.available_columns = (v)
488 483 end
489 484
490 485 def self.add_available_column(column)
491 486 self.available_columns << (column) if column.is_a?(QueryColumn)
492 487 end
493 488
494 489 # Returns an array of columns that can be used to group the results
495 490 def groupable_columns
496 491 available_columns.select {|c| c.groupable}
497 492 end
498 493
499 494 # Returns a Hash of columns and the key for sorting
500 495 def sortable_columns
501 496 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
502 497 h[column.name.to_s] = column.sortable
503 498 h
504 499 })
505 500 end
506 501
507 502 def columns
508 503 # preserve the column_names order
509 504 (has_default_columns? ? default_columns_names : column_names).collect do |name|
510 505 available_columns.find { |col| col.name == name }
511 506 end.compact
512 507 end
513 508
514 509 def default_columns_names
515 510 @default_columns_names ||= begin
516 511 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
517 512
518 513 project.present? ? default_columns : [:project] | default_columns
519 514 end
520 515 end
521 516
522 517 def column_names=(names)
523 518 if names
524 519 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
525 520 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
526 521 # Set column_names to nil if default columns
527 522 if names == default_columns_names
528 523 names = nil
529 524 end
530 525 end
531 526 write_attribute(:column_names, names)
532 527 end
533 528
534 529 def has_column?(column)
535 530 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
536 531 end
537 532
538 533 def has_default_columns?
539 534 column_names.nil? || column_names.empty?
540 535 end
541 536
542 537 def sort_criteria=(arg)
543 538 c = []
544 539 if arg.is_a?(Hash)
545 540 arg = arg.keys.sort.collect {|k| arg[k]}
546 541 end
547 542 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
548 543 write_attribute(:sort_criteria, c)
549 544 end
550 545
551 546 def sort_criteria
552 547 read_attribute(:sort_criteria) || []
553 548 end
554 549
555 550 def sort_criteria_key(arg)
556 551 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
557 552 end
558 553
559 554 def sort_criteria_order(arg)
560 555 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
561 556 end
562 557
563 558 def sort_criteria_order_for(key)
564 559 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
565 560 end
566 561
567 562 # Returns the SQL sort order that should be prepended for grouping
568 563 def group_by_sort_order
569 564 if grouped? && (column = group_by_column)
570 565 order = sort_criteria_order_for(column.name) || column.default_order
571 566 column.sortable.is_a?(Array) ?
572 567 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
573 568 "#{column.sortable} #{order}"
574 569 end
575 570 end
576 571
577 572 # Returns true if the query is a grouped query
578 573 def grouped?
579 574 !group_by_column.nil?
580 575 end
581 576
582 577 def group_by_column
583 578 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
584 579 end
585 580
586 581 def group_by_statement
587 582 group_by_column.try(:groupable)
588 583 end
589 584
590 585 def project_statement
591 586 project_clauses = []
592 587 if project && !project.descendants.active.empty?
593 588 ids = [project.id]
594 589 if has_filter?("subproject_id")
595 590 case operator_for("subproject_id")
596 591 when '='
597 592 # include the selected subprojects
598 593 ids += values_for("subproject_id").each(&:to_i)
599 594 when '!*'
600 595 # main project only
601 596 else
602 597 # all subprojects
603 598 ids += project.descendants.collect(&:id)
604 599 end
605 600 elsif Setting.display_subprojects_issues?
606 601 ids += project.descendants.collect(&:id)
607 602 end
608 603 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
609 604 elsif project
610 605 project_clauses << "#{Project.table_name}.id = %d" % project.id
611 606 end
612 607 project_clauses.any? ? project_clauses.join(' AND ') : nil
613 608 end
614 609
615 610 def statement
616 611 # filters clauses
617 612 filters_clauses = []
618 613 filters.each_key do |field|
619 614 next if field == "subproject_id"
620 615 v = values_for(field).clone
621 616 next unless v and !v.empty?
622 617 operator = operator_for(field)
623 618
624 619 # "me" value subsitution
625 620 if %w(assigned_to_id author_id watcher_id).include?(field)
626 621 if v.delete("me")
627 622 if User.current.logged?
628 623 v.push(User.current.id.to_s)
629 624 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
630 625 else
631 626 v.push("0")
632 627 end
633 628 end
634 629 end
635 630
636 631 if field == 'project_id'
637 632 if v.delete('mine')
638 633 v += User.current.memberships.map(&:project_id).map(&:to_s)
639 634 end
640 635 end
641 636
642 637 if field =~ /cf_(\d+)$/
643 638 # custom field
644 639 filters_clauses << sql_for_custom_field(field, operator, v, $1)
645 640 elsif respond_to?("sql_for_#{field}_field")
646 641 # specific statement
647 642 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
648 643 else
649 644 # regular field
650 645 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
651 646 end
652 647 end if filters and valid?
653 648
654 649 filters_clauses << project_statement
655 650 filters_clauses.reject!(&:blank?)
656 651
657 652 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
658 653 end
659 654
660 655 # Returns the issue count
661 656 def issue_count
662 657 Issue.visible.count(:include => [:status, :project], :conditions => statement)
663 658 rescue ::ActiveRecord::StatementInvalid => e
664 659 raise StatementInvalid.new(e.message)
665 660 end
666 661
667 662 # Returns the issue count by group or nil if query is not grouped
668 663 def issue_count_by_group
669 664 r = nil
670 665 if grouped?
671 666 begin
672 667 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
673 668 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
674 669 rescue ActiveRecord::RecordNotFound
675 670 r = {nil => issue_count}
676 671 end
677 672 c = group_by_column
678 673 if c.is_a?(QueryCustomFieldColumn)
679 674 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
680 675 end
681 676 end
682 677 r
683 678 rescue ::ActiveRecord::StatementInvalid => e
684 679 raise StatementInvalid.new(e.message)
685 680 end
686 681
687 682 # Returns the issues
688 683 # Valid options are :order, :offset, :limit, :include, :conditions
689 684 def issues(options={})
690 685 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
691 686 order_option = nil if order_option.blank?
692 687
693 688 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
694 689 :conditions => statement,
695 690 :order => order_option,
696 691 :joins => joins_for_order_statement(order_option),
697 692 :limit => options[:limit],
698 693 :offset => options[:offset]
699 694
700 695 if has_column?(:spent_hours)
701 696 Issue.load_visible_spent_hours(issues)
702 697 end
703 698 if has_column?(:relations)
704 699 Issue.load_visible_relations(issues)
705 700 end
706 701 issues
707 702 rescue ::ActiveRecord::StatementInvalid => e
708 703 raise StatementInvalid.new(e.message)
709 704 end
710 705
711 706 # Returns the issues ids
712 707 def issue_ids(options={})
713 708 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
714 709 order_option = nil if order_option.blank?
715 710
716 711 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
717 712 :conditions => statement,
718 713 :order => order_option,
719 714 :joins => joins_for_order_statement(order_option),
720 715 :limit => options[:limit],
721 716 :offset => options[:offset]).find_ids
722 717 rescue ::ActiveRecord::StatementInvalid => e
723 718 raise StatementInvalid.new(e.message)
724 719 end
725 720
726 721 # Returns the journals
727 722 # Valid options are :order, :offset, :limit
728 723 def journals(options={})
729 724 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
730 725 :conditions => statement,
731 726 :order => options[:order],
732 727 :limit => options[:limit],
733 728 :offset => options[:offset]
734 729 rescue ::ActiveRecord::StatementInvalid => e
735 730 raise StatementInvalid.new(e.message)
736 731 end
737 732
738 733 # Returns the versions
739 734 # Valid options are :conditions
740 735 def versions(options={})
741 736 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
742 737 rescue ::ActiveRecord::StatementInvalid => e
743 738 raise StatementInvalid.new(e.message)
744 739 end
745 740
746 741 def sql_for_watcher_id_field(field, operator, value)
747 742 db_table = Watcher.table_name
748 743 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
749 744 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
750 745 end
751 746
752 747 def sql_for_member_of_group_field(field, operator, value)
753 748 if operator == '*' # Any group
754 749 groups = Group.all
755 750 operator = '=' # Override the operator since we want to find by assigned_to
756 751 elsif operator == "!*"
757 752 groups = Group.all
758 753 operator = '!' # Override the operator since we want to find by assigned_to
759 754 else
760 755 groups = Group.find_all_by_id(value)
761 756 end
762 757 groups ||= []
763 758
764 759 members_of_groups = groups.inject([]) {|user_ids, group|
765 760 if group && group.user_ids.present?
766 761 user_ids << group.user_ids
767 762 end
768 763 user_ids.flatten.uniq.compact
769 764 }.sort.collect(&:to_s)
770 765
771 766 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
772 767 end
773 768
774 769 def sql_for_assigned_to_role_field(field, operator, value)
775 770 case operator
776 771 when "*", "!*" # Member / Not member
777 772 sw = operator == "!*" ? 'NOT' : ''
778 773 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
779 774 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
780 775 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
781 776 when "=", "!"
782 777 role_cond = value.any? ?
783 778 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
784 779 "1=0"
785 780
786 781 sw = operator == "!" ? 'NOT' : ''
787 782 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
788 783 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
789 784 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
790 785 end
791 786 end
792 787
793 788 def sql_for_is_private_field(field, operator, value)
794 789 op = (operator == "=" ? 'IN' : 'NOT IN')
795 790 va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
796 791
797 792 "#{Issue.table_name}.is_private #{op} (#{va})"
798 793 end
799 794
800 795 def sql_for_relations(field, operator, value, options={})
801 796 relation_options = IssueRelation::TYPES[field]
802 797 return relation_options unless relation_options
803 798
804 799 relation_type = field
805 800 join_column, target_join_column = "issue_from_id", "issue_to_id"
806 801 if relation_options[:reverse] || options[:reverse]
807 802 relation_type = relation_options[:reverse] || relation_type
808 803 join_column, target_join_column = target_join_column, join_column
809 804 end
810 805
811 806 sql = case operator
812 807 when "*", "!*"
813 808 op = (operator == "*" ? 'IN' : 'NOT IN')
814 809 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
815 810 when "=", "!"
816 811 op = (operator == "=" ? 'IN' : 'NOT IN')
817 812 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
818 813 when "=p", "=!p", "!p"
819 814 op = (operator == "!p" ? 'NOT IN' : 'IN')
820 815 comp = (operator == "=!p" ? '<>' : '=')
821 816 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
822 817 end
823 818
824 819 if relation_options[:sym] == field && !options[:reverse]
825 820 sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
826 821 sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
827 822 else
828 823 sql
829 824 end
830 825 end
831 826
832 827 IssueRelation::TYPES.keys.each do |relation_type|
833 828 alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
834 829 end
835 830
836 831 private
837 832
838 833 def sql_for_custom_field(field, operator, value, custom_field_id)
839 834 db_table = CustomValue.table_name
840 835 db_field = 'value'
841 836 filter = @available_filters[field]
842 837 return nil unless filter
843 838 if filter[:format] == 'user'
844 839 if value.delete('me')
845 840 value.push User.current.id.to_s
846 841 end
847 842 end
848 843 not_in = nil
849 844 if operator == '!'
850 845 # Makes ! operator work for custom fields with multiple values
851 846 operator = '='
852 847 not_in = 'NOT'
853 848 end
854 849 customized_key = "id"
855 850 customized_class = Issue
856 851 if field =~ /^(.+)\.cf_/
857 852 assoc = $1
858 853 customized_key = "#{assoc}_id"
859 854 customized_class = Issue.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
860 855 raise "Unknown Issue association #{assoc}" unless customized_class
861 856 end
862 857 "#{Issue.table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
863 858 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
864 859 end
865 860
866 861 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
867 862 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
868 863 sql = ''
869 864 case operator
870 865 when "="
871 866 if value.any?
872 867 case type_for(field)
873 868 when :date, :date_past
874 869 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
875 870 when :integer
876 871 if is_custom_filter
877 872 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
878 873 else
879 874 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
880 875 end
881 876 when :float
882 877 if is_custom_filter
883 878 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
884 879 else
885 880 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
886 881 end
887 882 else
888 883 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
889 884 end
890 885 else
891 886 # IN an empty set
892 887 sql = "1=0"
893 888 end
894 889 when "!"
895 890 if value.any?
896 891 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
897 892 else
898 893 # NOT IN an empty set
899 894 sql = "1=1"
900 895 end
901 896 when "!*"
902 897 sql = "#{db_table}.#{db_field} IS NULL"
903 898 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
904 899 when "*"
905 900 sql = "#{db_table}.#{db_field} IS NOT NULL"
906 901 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
907 902 when ">="
908 903 if [:date, :date_past].include?(type_for(field))
909 904 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
910 905 else
911 906 if is_custom_filter
912 907 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
913 908 else
914 909 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
915 910 end
916 911 end
917 912 when "<="
918 913 if [:date, :date_past].include?(type_for(field))
919 914 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
920 915 else
921 916 if is_custom_filter
922 917 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
923 918 else
924 919 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
925 920 end
926 921 end
927 922 when "><"
928 923 if [:date, :date_past].include?(type_for(field))
929 924 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
930 925 else
931 926 if is_custom_filter
932 927 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
933 928 else
934 929 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
935 930 end
936 931 end
937 932 when "o"
938 933 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
939 934 when "c"
940 935 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
941 936 when "><t-"
942 937 # between today - n days and today
943 938 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
944 939 when ">t-"
945 940 # >= today - n days
946 941 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
947 942 when "<t-"
948 943 # <= today - n days
949 944 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
950 945 when "t-"
951 946 # = n days in past
952 947 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
953 948 when "><t+"
954 949 # between today and today + n days
955 950 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
956 951 when ">t+"
957 952 # >= today + n days
958 953 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
959 954 when "<t+"
960 955 # <= today + n days
961 956 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
962 957 when "t+"
963 958 # = today + n days
964 959 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
965 960 when "t"
966 961 # = today
967 962 sql = relative_date_clause(db_table, db_field, 0, 0)
968 963 when "w"
969 964 # = this week
970 965 first_day_of_week = l(:general_first_day_of_week).to_i
971 966 day_of_week = Date.today.cwday
972 967 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
973 968 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
974 969 when "~"
975 970 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
976 971 when "!~"
977 972 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
978 973 else
979 974 raise "Unknown query operator #{operator}"
980 975 end
981 976
982 977 return sql
983 978 end
984 979
985 980 def add_custom_fields_filters(custom_fields, assoc=nil)
986 981 return unless custom_fields.present?
987 982 @available_filters ||= {}
988 983
989 984 custom_fields.select(&:is_filter?).each do |field|
990 985 case field.field_format
991 986 when "text"
992 987 options = { :type => :text, :order => 20 }
993 988 when "list"
994 989 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
995 990 when "date"
996 991 options = { :type => :date, :order => 20 }
997 992 when "bool"
998 993 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
999 994 when "int"
1000 995 options = { :type => :integer, :order => 20 }
1001 996 when "float"
1002 997 options = { :type => :float, :order => 20 }
1003 998 when "user", "version"
1004 999 next unless project
1005 1000 values = field.possible_values_options(project)
1006 1001 if User.current.logged? && field.field_format == 'user'
1007 1002 values.unshift ["<< #{l(:label_me)} >>", "me"]
1008 1003 end
1009 1004 options = { :type => :list_optional, :values => values, :order => 20}
1010 1005 else
1011 1006 options = { :type => :string, :order => 20 }
1012 1007 end
1013 1008 filter_id = "cf_#{field.id}"
1014 1009 filter_name = field.name
1015 1010 if assoc.present?
1016 1011 filter_id = "#{assoc}.#{filter_id}"
1017 1012 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1018 1013 end
1019 1014 @available_filters[filter_id] = options.merge({
1020 1015 :name => filter_name,
1021 1016 :format => field.field_format,
1022 1017 :field => field
1023 1018 })
1024 1019 end
1025 1020 end
1026 1021
1027 1022 def add_associations_custom_fields_filters(*associations)
1028 1023 fields_by_class = CustomField.where(:is_filter => true).group_by(&:class)
1029 1024 associations.each do |assoc|
1030 1025 association_klass = Issue.reflect_on_association(assoc).klass
1031 1026 fields_by_class.each do |field_class, fields|
1032 1027 if field_class.customized_class <= association_klass
1033 1028 add_custom_fields_filters(fields, assoc)
1034 1029 end
1035 1030 end
1036 1031 end
1037 1032 end
1038 1033
1039 1034 # Returns a SQL clause for a date or datetime field.
1040 1035 def date_clause(table, field, from, to)
1041 1036 s = []
1042 1037 if from
1043 1038 from_yesterday = from - 1
1044 1039 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
1045 1040 if self.class.default_timezone == :utc
1046 1041 from_yesterday_time = from_yesterday_time.utc
1047 1042 end
1048 1043 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
1049 1044 end
1050 1045 if to
1051 1046 to_time = Time.local(to.year, to.month, to.day)
1052 1047 if self.class.default_timezone == :utc
1053 1048 to_time = to_time.utc
1054 1049 end
1055 1050 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
1056 1051 end
1057 1052 s.join(' AND ')
1058 1053 end
1059 1054
1060 1055 # Returns a SQL clause for a date or datetime field using relative dates.
1061 1056 def relative_date_clause(table, field, days_from, days_to)
1062 1057 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
1063 1058 end
1064 1059
1065 1060 # Additional joins required for the given sort options
1066 1061 def joins_for_order_statement(order_options)
1067 1062 joins = []
1068 1063
1069 1064 if order_options
1070 1065 if order_options.include?('authors')
1071 1066 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{Issue.table_name}.author_id"
1072 1067 end
1073 1068 order_options.scan(/cf_\d+/).uniq.each do |name|
1074 1069 column = available_columns.detect {|c| c.name.to_s == name}
1075 1070 join = column && column.custom_field.join_for_order_statement
1076 1071 if join
1077 1072 joins << join
1078 1073 end
1079 1074 end
1080 1075 end
1081 1076
1082 1077 joins.any? ? joins.join(' ') : nil
1083 1078 end
1084 1079 end
@@ -1,435 +1,435
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 ScmFetchError < Exception; end
19 19
20 20 class Repository < ActiveRecord::Base
21 21 include Redmine::Ciphering
22 22 include Redmine::SafeAttributes
23 23
24 24 # Maximum length for repository identifiers
25 25 IDENTIFIER_MAX_LENGTH = 255
26 26
27 27 belongs_to :project
28 28 has_many :changesets, :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC"
29 29 has_many :filechanges, :class_name => 'Change', :through => :changesets
30 30
31 31 serialize :extra_info
32 32
33 33 before_save :check_default
34 34
35 35 # Raw SQL to delete changesets and changes in the database
36 36 # has_many :changesets, :dependent => :destroy is too slow for big repositories
37 37 before_destroy :clear_changesets
38 38
39 39 validates_length_of :password, :maximum => 255, :allow_nil => true
40 40 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
41 41 validates_presence_of :identifier, :unless => Proc.new { |r| r.is_default? || r.set_as_default? }
42 42 validates_uniqueness_of :identifier, :scope => :project_id, :allow_blank => true
43 43 validates_exclusion_of :identifier, :in => %w(show entry raw changes annotate diff show stats graph)
44 44 # donwcase letters, digits, dashes, underscores but not digits only
45 45 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :allow_blank => true
46 46 # Checks if the SCM is enabled when creating a repository
47 47 validate :repo_create_validation, :on => :create
48 48
49 49 safe_attributes 'identifier',
50 50 'login',
51 51 'password',
52 52 'path_encoding',
53 53 'log_encoding',
54 54 'is_default'
55 55
56 56 safe_attributes 'url',
57 57 :if => lambda {|repository, user| repository.new_record?}
58 58
59 59 def repo_create_validation
60 60 unless Setting.enabled_scm.include?(self.class.name.demodulize)
61 61 errors.add(:type, :invalid)
62 62 end
63 63 end
64 64
65 65 def self.human_attribute_name(attribute_key_name, *args)
66 66 attr_name = attribute_key_name.to_s
67 67 if attr_name == "log_encoding"
68 68 attr_name = "commit_logs_encoding"
69 69 end
70 70 super(attr_name, *args)
71 71 end
72 72
73 73 # Removes leading and trailing whitespace
74 74 def url=(arg)
75 75 write_attribute(:url, arg ? arg.to_s.strip : nil)
76 76 end
77 77
78 78 # Removes leading and trailing whitespace
79 79 def root_url=(arg)
80 80 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
81 81 end
82 82
83 83 def password
84 84 read_ciphered_attribute(:password)
85 85 end
86 86
87 87 def password=(arg)
88 88 write_ciphered_attribute(:password, arg)
89 89 end
90 90
91 91 def scm_adapter
92 92 self.class.scm_adapter_class
93 93 end
94 94
95 95 def scm
96 96 unless @scm
97 97 @scm = self.scm_adapter.new(url, root_url,
98 98 login, password, path_encoding)
99 99 if root_url.blank? && @scm.root_url.present?
100 100 update_attribute(:root_url, @scm.root_url)
101 101 end
102 102 end
103 103 @scm
104 104 end
105 105
106 106 def scm_name
107 107 self.class.scm_name
108 108 end
109 109
110 110 def name
111 111 if identifier.present?
112 112 identifier
113 113 elsif is_default?
114 114 l(:field_repository_is_default)
115 115 else
116 116 scm_name
117 117 end
118 118 end
119 119
120 120 def identifier=(identifier)
121 121 super unless identifier_frozen?
122 122 end
123 123
124 124 def identifier_frozen?
125 125 errors[:identifier].blank? && !(new_record? || identifier.blank?)
126 126 end
127 127
128 128 def identifier_param
129 129 if is_default?
130 130 nil
131 131 elsif identifier.present?
132 132 identifier
133 133 else
134 134 id.to_s
135 135 end
136 136 end
137 137
138 138 def <=>(repository)
139 139 if is_default?
140 140 -1
141 141 elsif repository.is_default?
142 142 1
143 143 else
144 144 identifier.to_s <=> repository.identifier.to_s
145 145 end
146 146 end
147 147
148 148 def self.find_by_identifier_param(param)
149 149 if param.to_s =~ /^\d+$/
150 150 find_by_id(param)
151 151 else
152 152 find_by_identifier(param)
153 153 end
154 154 end
155 155
156 156 def merge_extra_info(arg)
157 157 h = extra_info || {}
158 158 return h if arg.nil?
159 159 h.merge!(arg)
160 160 write_attribute(:extra_info, h)
161 161 end
162 162
163 163 def report_last_commit
164 164 true
165 165 end
166 166
167 167 def supports_cat?
168 168 scm.supports_cat?
169 169 end
170 170
171 171 def supports_annotate?
172 172 scm.supports_annotate?
173 173 end
174 174
175 175 def supports_all_revisions?
176 176 true
177 177 end
178 178
179 179 def supports_directory_revisions?
180 180 false
181 181 end
182 182
183 183 def supports_revision_graph?
184 184 false
185 185 end
186 186
187 187 def entry(path=nil, identifier=nil)
188 188 scm.entry(path, identifier)
189 189 end
190 190
191 191 def entries(path=nil, identifier=nil)
192 192 entries = scm.entries(path, identifier)
193 193 load_entries_changesets(entries)
194 194 entries
195 195 end
196 196
197 197 def branches
198 198 scm.branches
199 199 end
200 200
201 201 def tags
202 202 scm.tags
203 203 end
204 204
205 205 def default_branch
206 206 nil
207 207 end
208 208
209 209 def properties(path, identifier=nil)
210 210 scm.properties(path, identifier)
211 211 end
212 212
213 213 def cat(path, identifier=nil)
214 214 scm.cat(path, identifier)
215 215 end
216 216
217 217 def diff(path, rev, rev_to)
218 218 scm.diff(path, rev, rev_to)
219 219 end
220 220
221 221 def diff_format_revisions(cs, cs_to, sep=':')
222 222 text = ""
223 223 text << cs_to.format_identifier + sep if cs_to
224 224 text << cs.format_identifier if cs
225 225 text
226 226 end
227 227
228 228 # Returns a path relative to the url of the repository
229 229 def relative_path(path)
230 230 path
231 231 end
232 232
233 233 # Finds and returns a revision with a number or the beginning of a hash
234 234 def find_changeset_by_name(name)
235 235 return nil if name.blank?
236 236 s = name.to_s
237 237 changesets.find(:first, :conditions => (s.match(/^\d*$/) ?
238 238 ["revision = ?", s] : ["revision LIKE ?", s + '%']))
239 239 end
240 240
241 241 def latest_changeset
242 242 @latest_changeset ||= changesets.find(:first)
243 243 end
244 244
245 245 # Returns the latest changesets for +path+
246 246 # Default behaviour is to search in cached changesets
247 247 def latest_changesets(path, rev, limit=10)
248 248 if path.blank?
249 249 changesets.find(
250 250 :all,
251 251 :include => :user,
252 252 :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC",
253 253 :limit => limit)
254 254 else
255 255 filechanges.find(
256 256 :all,
257 257 :include => {:changeset => :user},
258 258 :conditions => ["path = ?", path.with_leading_slash],
259 259 :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC",
260 260 :limit => limit
261 261 ).collect(&:changeset)
262 262 end
263 263 end
264 264
265 265 def scan_changesets_for_issue_ids
266 266 self.changesets.each(&:scan_comment_for_issue_ids)
267 267 end
268 268
269 269 # Returns an array of committers usernames and associated user_id
270 270 def committers
271 271 @committers ||= Changeset.connection.select_rows(
272 272 "SELECT DISTINCT committer, user_id FROM #{Changeset.table_name} WHERE repository_id = #{id}")
273 273 end
274 274
275 275 # Maps committers username to a user ids
276 276 def committer_ids=(h)
277 277 if h.is_a?(Hash)
278 278 committers.each do |committer, user_id|
279 279 new_user_id = h[committer]
280 280 if new_user_id && (new_user_id.to_i != user_id.to_i)
281 281 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
282 282 Changeset.update_all(
283 283 "user_id = #{ new_user_id.nil? ? 'NULL' : new_user_id }",
284 284 ["repository_id = ? AND committer = ?", id, committer])
285 285 end
286 286 end
287 287 @committers = nil
288 288 @found_committer_users = nil
289 289 true
290 290 else
291 291 false
292 292 end
293 293 end
294 294
295 295 # Returns the Redmine User corresponding to the given +committer+
296 296 # It will return nil if the committer is not yet mapped and if no User
297 297 # with the same username or email was found
298 298 def find_committer_user(committer)
299 299 unless committer.blank?
300 300 @found_committer_users ||= {}
301 301 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
302 302
303 303 user = nil
304 304 c = changesets.find(:first, :conditions => {:committer => committer}, :include => :user)
305 305 if c && c.user
306 306 user = c.user
307 307 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
308 308 username, email = $1.strip, $3
309 309 u = User.find_by_login(username)
310 310 u ||= User.find_by_mail(email) unless email.blank?
311 311 user = u
312 312 end
313 313 @found_committer_users[committer] = user
314 314 user
315 315 end
316 316 end
317 317
318 318 def repo_log_encoding
319 319 encoding = log_encoding.to_s.strip
320 320 encoding.blank? ? 'UTF-8' : encoding
321 321 end
322 322
323 323 # Fetches new changesets for all repositories of active projects
324 324 # Can be called periodically by an external script
325 325 # eg. ruby script/runner "Repository.fetch_changesets"
326 326 def self.fetch_changesets
327 327 Project.active.has_module(:repository).all.each do |project|
328 328 project.repositories.each do |repository|
329 329 begin
330 330 repository.fetch_changesets
331 331 rescue Redmine::Scm::Adapters::CommandFailed => e
332 332 logger.error "scm: error during fetching changesets: #{e.message}"
333 333 end
334 334 end
335 335 end
336 336 end
337 337
338 338 # scan changeset comments to find related and fixed issues for all repositories
339 339 def self.scan_changesets_for_issue_ids
340 find(:all).each(&:scan_changesets_for_issue_ids)
340 all.each(&:scan_changesets_for_issue_ids)
341 341 end
342 342
343 343 def self.scm_name
344 344 'Abstract'
345 345 end
346 346
347 347 def self.available_scm
348 348 subclasses.collect {|klass| [klass.scm_name, klass.name]}
349 349 end
350 350
351 351 def self.factory(klass_name, *args)
352 352 klass = "Repository::#{klass_name}".constantize
353 353 klass.new(*args)
354 354 rescue
355 355 nil
356 356 end
357 357
358 358 def self.scm_adapter_class
359 359 nil
360 360 end
361 361
362 362 def self.scm_command
363 363 ret = ""
364 364 begin
365 365 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
366 366 rescue Exception => e
367 367 logger.error "scm: error during get command: #{e.message}"
368 368 end
369 369 ret
370 370 end
371 371
372 372 def self.scm_version_string
373 373 ret = ""
374 374 begin
375 375 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
376 376 rescue Exception => e
377 377 logger.error "scm: error during get version string: #{e.message}"
378 378 end
379 379 ret
380 380 end
381 381
382 382 def self.scm_available
383 383 ret = false
384 384 begin
385 385 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
386 386 rescue Exception => e
387 387 logger.error "scm: error during get scm available: #{e.message}"
388 388 end
389 389 ret
390 390 end
391 391
392 392 def set_as_default?
393 393 new_record? && project && !Repository.first(:conditions => {:project_id => project.id})
394 394 end
395 395
396 396 protected
397 397
398 398 def check_default
399 399 if !is_default? && set_as_default?
400 400 self.is_default = true
401 401 end
402 402 if is_default? && is_default_changed?
403 403 Repository.update_all(["is_default = ?", false], ["project_id = ?", project_id])
404 404 end
405 405 end
406 406
407 407 def load_entries_changesets(entries)
408 408 if entries
409 409 entries.each do |entry|
410 410 if entry.lastrev && entry.lastrev.identifier
411 411 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
412 412 end
413 413 end
414 414 end
415 415 end
416 416
417 417 private
418 418
419 419 # Deletes repository data
420 420 def clear_changesets
421 421 cs = Changeset.table_name
422 422 ch = Change.table_name
423 423 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
424 424 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
425 425
426 426 connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
427 427 connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
428 428 connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
429 429 connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
430 430 clear_extra_info_of_changesets
431 431 end
432 432
433 433 def clear_extra_info_of_changesets
434 434 end
435 435 end
@@ -1,158 +1,159
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 'redmine/scm/adapters/mercurial_adapter'
19 19
20 20 class Repository::Mercurial < Repository
21 21 # sort changesets by revision number
22 22 has_many :changesets,
23 23 :order => "#{Changeset.table_name}.id DESC",
24 24 :foreign_key => 'repository_id'
25 25
26 26 attr_protected :root_url
27 27 validates_presence_of :url
28 28
29 29 # number of changesets to fetch at once
30 30 FETCH_AT_ONCE = 100
31 31
32 32 def self.human_attribute_name(attribute_key_name, *args)
33 33 attr_name = attribute_key_name.to_s
34 34 if attr_name == "url"
35 35 attr_name = "path_to_repository"
36 36 end
37 37 super(attr_name, *args)
38 38 end
39 39
40 40 def self.scm_adapter_class
41 41 Redmine::Scm::Adapters::MercurialAdapter
42 42 end
43 43
44 44 def self.scm_name
45 45 'Mercurial'
46 46 end
47 47
48 48 def supports_directory_revisions?
49 49 true
50 50 end
51 51
52 52 def supports_revision_graph?
53 53 true
54 54 end
55 55
56 56 def repo_log_encoding
57 57 'UTF-8'
58 58 end
59 59
60 60 # Returns the readable identifier for the given mercurial changeset
61 61 def self.format_changeset_identifier(changeset)
62 62 "#{changeset.revision}:#{changeset.scmid}"
63 63 end
64 64
65 65 # Returns the identifier for the given Mercurial changeset
66 66 def self.changeset_identifier(changeset)
67 67 changeset.scmid
68 68 end
69 69
70 70 def diff_format_revisions(cs, cs_to, sep=':')
71 71 super(cs, cs_to, ' ')
72 72 end
73 73
74 74 # Finds and returns a revision with a number or the beginning of a hash
75 75 def find_changeset_by_name(name)
76 76 return nil if name.blank?
77 77 s = name.to_s
78 78 if /[^\d]/ =~ s or s.size > 8
79 79 cs = changesets.where(:scmid => s).first
80 80 else
81 81 cs = changesets.where(:revision => s).first
82 82 end
83 83 return cs if cs
84 84 changesets.where('scmid LIKE ?', "#{s}%").first
85 85 end
86 86
87 87 # Returns the latest changesets for +path+; sorted by revision number
88 88 #
89 89 # Because :order => 'id DESC' is defined at 'has_many',
90 90 # there is no need to set 'order'.
91 91 # But, MySQL test fails.
92 92 # Sqlite3 and PostgreSQL pass.
93 93 # Is this MySQL bug?
94 94 def latest_changesets(path, rev, limit=10)
95 changesets.find(:all,
96 :include => :user,
97 :conditions => latest_changesets_cond(path, rev, limit),
98 :limit => limit,
99 :order => "#{Changeset.table_name}.id DESC")
95 changesets.
96 includes(:user).
97 where(latest_changesets_cond(path, rev, limit)).
98 limit(limit).
99 order("#{Changeset.table_name}.id DESC").
100 all
100 101 end
101 102
102 103 def latest_changesets_cond(path, rev, limit)
103 104 cond, args = [], []
104 105 if scm.branchmap.member? rev
105 106 # Mercurial named branch is *stable* in each revision.
106 107 # So, named branch can be stored in database.
107 108 # Mercurial provides *bookmark* which is equivalent with git branch.
108 109 # But, bookmark is not implemented.
109 110 cond << "#{Changeset.table_name}.scmid IN (?)"
110 111 # Revisions in root directory and sub directory are not equal.
111 112 # So, in order to get correct limit, we need to get all revisions.
112 113 # But, it is very heavy.
113 114 # Mercurial does not treat direcotry.
114 115 # So, "hg log DIR" is very heavy.
115 116 branch_limit = path.blank? ? limit : ( limit * 5 )
116 117 args << scm.nodes_in_branch(rev, :limit => branch_limit)
117 118 elsif last = rev ? find_changeset_by_name(scm.tagmap[rev] || rev) : nil
118 119 cond << "#{Changeset.table_name}.id <= ?"
119 120 args << last.id
120 121 end
121 122 unless path.blank?
122 123 cond << "EXISTS (SELECT * FROM #{Change.table_name}
123 124 WHERE #{Change.table_name}.changeset_id = #{Changeset.table_name}.id
124 125 AND (#{Change.table_name}.path = ?
125 126 OR #{Change.table_name}.path LIKE ? ESCAPE ?))"
126 127 args << path.with_leading_slash
127 128 args << "#{path.with_leading_slash.gsub(%r{[%_\\]}) { |s| "\\#{s}" }}/%" << '\\'
128 129 end
129 130 [cond.join(' AND '), *args] unless cond.empty?
130 131 end
131 132 private :latest_changesets_cond
132 133
133 134 def fetch_changesets
134 135 return if scm.info.nil?
135 136 scm_rev = scm.info.lastrev.revision.to_i
136 137 db_rev = latest_changeset ? latest_changeset.revision.to_i : -1
137 138 return unless db_rev < scm_rev # already up-to-date
138 139
139 140 logger.debug "Fetching changesets for repository #{url}" if logger
140 141 (db_rev + 1).step(scm_rev, FETCH_AT_ONCE) do |i|
141 142 scm.each_revision('', i, [i + FETCH_AT_ONCE - 1, scm_rev].min) do |re|
142 143 transaction do
143 144 parents = (re.parents || []).collect{|rp| find_changeset_by_name(rp)}.compact
144 145 cs = Changeset.create(:repository => self,
145 146 :revision => re.revision,
146 147 :scmid => re.scmid,
147 148 :committer => re.author,
148 149 :committed_on => re.time,
149 150 :comments => re.message,
150 151 :parents => parents)
151 152 unless cs.new_record?
152 153 re.paths.each { |e| cs.create_change(e) }
153 154 end
154 155 end
155 156 end
156 157 end
157 158 end
158 159 end
@@ -1,66 +1,66
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Watcher < ActiveRecord::Base
19 19 belongs_to :watchable, :polymorphic => true
20 20 belongs_to :user
21 21
22 22 validates_presence_of :user
23 23 validates_uniqueness_of :user_id, :scope => [:watchable_type, :watchable_id]
24 24 validate :validate_user
25 25
26 26 # Unwatch things that users are no longer allowed to view
27 27 def self.prune(options={})
28 28 if options.has_key?(:user)
29 29 prune_single_user(options[:user], options)
30 30 else
31 31 pruned = 0
32 User.find(:all, :conditions => "id IN (SELECT DISTINCT user_id FROM #{table_name})").each do |user|
32 User.where("id IN (SELECT DISTINCT user_id FROM #{table_name})").all.each do |user|
33 33 pruned += prune_single_user(user, options)
34 34 end
35 35 pruned
36 36 end
37 37 end
38 38
39 39 protected
40 40
41 41 def validate_user
42 42 errors.add :user_id, :invalid unless user.nil? || user.active?
43 43 end
44 44
45 45 private
46 46
47 47 def self.prune_single_user(user, options={})
48 48 return unless user.is_a?(User)
49 49 pruned = 0
50 find(:all, :conditions => {:user_id => user.id}).each do |watcher|
50 where(:user_id => user.id).all.each do |watcher|
51 51 next if watcher.watchable.nil?
52 52
53 53 if options.has_key?(:project)
54 54 next unless watcher.watchable.respond_to?(:project) && watcher.watchable.project == options[:project]
55 55 end
56 56
57 57 if watcher.watchable.respond_to?(:visible?)
58 58 unless watcher.watchable.visible?(user)
59 59 watcher.destroy
60 60 pruned += 1
61 61 end
62 62 end
63 63 end
64 64 pruned
65 65 end
66 66 end
@@ -1,65 +1,65
1 1 <% roles = Role.find_all_givable %>
2 <% projects = Project.active.find(:all, :order => 'lft') %>
2 <% projects = Project.active.all %>
3 3
4 4 <div class="splitcontentleft">
5 5 <% if @group.memberships.any? %>
6 6 <table class="list memberships">
7 7 <thead><tr>
8 8 <th><%= l(:label_project) %></th>
9 9 <th><%= l(:label_role_plural) %></th>
10 10 <th style="width:15%"></th>
11 11 </tr></thead>
12 12 <tbody>
13 13 <% @group.memberships.each do |membership| %>
14 14 <% next if membership.new_record? %>
15 15 <tr id="member-<%= membership.id %>" class="<%= cycle 'odd', 'even' %> class">
16 16 <td class="project"><%=h membership.project %></td>
17 17 <td class="roles">
18 18 <span id="member-<%= membership.id %>-roles"><%=h membership.roles.sort.collect(&:to_s).join(', ') %></span>
19 19 <%= form_for(:membership, :remote => true,
20 20 :url => { :action => 'edit_membership', :id => @group, :membership_id => membership },
21 21 :html => { :id => "member-#{membership.id}-roles-form", :style => 'display:none;'}) do %>
22 22 <p><% roles.each do |role| %>
23 23 <label><%= check_box_tag 'membership[role_ids][]', role.id, membership.roles.include?(role) %> <%=h role %></label><br />
24 24 <% end %></p>
25 25 <p><%= submit_tag l(:button_change) %>
26 26 <%= link_to_function(
27 27 l(:button_cancel),
28 28 "$('#member-#{membership.id}-roles').show(); $('#member-#{membership.id}-roles-form').hide(); return false;"
29 29 ) %></p>
30 30 <% end %>
31 31 </td>
32 32 <td class="buttons">
33 33 <%= link_to_function(
34 34 l(:button_edit),
35 35 "$('#member-#{membership.id}-roles').hide(); $('#member-#{membership.id}-roles-form').show(); return false;",
36 36 :class => 'icon icon-edit'
37 37 ) %>
38 38 <%= delete_link({:controller => 'groups', :action => 'destroy_membership', :id => @group, :membership_id => membership},
39 39 :remote => true,
40 40 :method => :post) %>
41 41 </td>
42 42 </tr>
43 43 <% end; reset_cycle %>
44 44 </tbody>
45 45 </table>
46 46 <% else %>
47 47 <p class="nodata"><%= l(:label_no_data) %></p>
48 48 <% end %>
49 49 </div>
50 50
51 51 <div class="splitcontentright">
52 52 <% if projects.any? %>
53 53 <fieldset><legend><%=l(:label_project_new)%></legend>
54 54 <%= form_for(:membership, :remote => true, :url => { :action => 'edit_membership', :id => @group }) do %>
55 55 <%= label_tag "membership_project_id", l(:description_choose_project), :class => "hidden-for-sighted" %>
56 56 <%= select_tag 'membership[project_id]', options_for_membership_project_select(@group, projects) %>
57 57 <p><%= l(:label_role_plural) %>:
58 58 <% roles.each do |role| %>
59 59 <label><%= check_box_tag 'membership[role_ids][]', role.id %> <%=h role %></label>
60 60 <% end %></p>
61 61 <p><%= submit_tag l(:button_add) %></p>
62 62 <% end %>
63 63 </fieldset>
64 64 <% end %>
65 65 </div>
@@ -1,77 +1,77
1 1 <%= error_messages_for 'member' %>
2 2 <% roles = Role.find_all_givable
3 members = @project.member_principals.find(:all, :include => [:roles, :principal]).sort %>
3 members = @project.member_principals.includes(:roles, :principal).all.sort %>
4 4
5 5 <div class="splitcontentleft">
6 6 <% if members.any? %>
7 7 <table class="list members">
8 8 <thead><tr>
9 9 <th><%= l(:label_user) %> / <%= l(:label_group) %></th>
10 10 <th><%= l(:label_role_plural) %></th>
11 11 <th style="width:15%"></th>
12 12 <%= call_hook(:view_projects_settings_members_table_header, :project => @project) %>
13 13 </tr></thead>
14 14 <tbody>
15 15 <% members.each do |member| %>
16 16 <% next if member.new_record? %>
17 17 <tr id="member-<%= member.id %>" class="<%= cycle 'odd', 'even' %> member">
18 18 <td class="<%= member.principal.class.name.downcase %>"><%= link_to_user member.principal %></td>
19 19 <td class="roles">
20 20 <span id="member-<%= member.id %>-roles"><%=h member.roles.sort.collect(&:to_s).join(', ') %></span>
21 21 <%= form_for(member, {:as => :membership, :remote => true, :url => membership_path(member),
22 22 :method => :put,
23 23 :html => { :id => "member-#{member.id}-roles-form", :class => 'hol' }}
24 24 ) do |f| %>
25 25 <p><% roles.each do |role| %>
26 26 <label><%= check_box_tag 'membership[role_ids][]', role.id, member.roles.include?(role),
27 27 :disabled => member.member_roles.detect {|mr| mr.role_id == role.id && !mr.inherited_from.nil?} %> <%=h role %></label><br />
28 28 <% end %></p>
29 29 <%= hidden_field_tag 'membership[role_ids][]', '' %>
30 30 <p><%= submit_tag l(:button_change), :class => "small" %>
31 31 <%= link_to_function l(:button_cancel),
32 32 "$('#member-#{member.id}-roles').show(); $('#member-#{member.id}-roles-form').hide(); return false;"
33 33 %></p>
34 34 <% end %>
35 35 </td>
36 36 <td class="buttons">
37 37 <%= link_to_function l(:button_edit),
38 38 "$('#member-#{member.id}-roles').hide(); $('#member-#{member.id}-roles-form').show(); return false;",
39 39 :class => 'icon icon-edit' %>
40 40 <%= delete_link membership_path(member),
41 41 :remote => true,
42 42 :data => (!User.current.admin? && member.include?(User.current) ? {:confirm => l(:text_own_membership_delete_confirmation)} : {}) if member.deletable? %>
43 43 </td>
44 44 <%= call_hook(:view_projects_settings_members_table_row, { :project => @project, :member => member}) %>
45 45 </tr>
46 46 <% end; reset_cycle %>
47 47 </tbody>
48 48 </table>
49 49 <% else %>
50 50 <p class="nodata"><%= l(:label_no_data) %></p>
51 51 <% end %>
52 52 </div>
53 53
54 54 <% principals = Principal.active.not_member_of(@project).all(:limit => 100, :order => 'type, login, lastname ASC') %>
55 55
56 56 <div class="splitcontentright">
57 57 <% if roles.any? && principals.any? %>
58 58 <%= form_for(@member, {:as => :membership, :url => project_memberships_path(@project), :remote => true, :method => :post}) do |f| %>
59 59 <fieldset><legend><%=l(:label_member_new)%></legend>
60 60
61 61 <p><%= label_tag "principal_search", l(:label_principal_search) %><%= text_field_tag 'principal_search', nil %></p>
62 62 <%= javascript_tag "observeSearchfield('principal_search', 'principals', '#{ escape_javascript autocomplete_project_memberships_path(@project) }')" %>
63 63
64 64 <div id="principals">
65 65 <%= principals_check_box_tags 'membership[user_ids][]', principals %>
66 66 </div>
67 67
68 68 <p><%= l(:label_role_plural) %>:
69 69 <% roles.each do |role| %>
70 70 <label><%= check_box_tag 'membership[role_ids][]', role.id %> <%=h role %></label>
71 71 <% end %></p>
72 72
73 73 <p><%= submit_tag l(:button_add), :id => 'member-add-submit' %></p>
74 74 </fieldset>
75 75 <% end %>
76 76 <% end %>
77 77 </div>
@@ -1,94 +1,94
1 1 <%= form_tag({:action => 'edit', :tab => 'repositories'}) do %>
2 2
3 3 <fieldset class="box settings enabled_scm">
4 4 <legend><%= l(:setting_enabled_scm) %></legend>
5 5 <%= hidden_field_tag 'settings[enabled_scm][]', '' %>
6 6 <table>
7 7 <tr>
8 8 <th></th>
9 9 <th><%= l(:text_scm_command) %></th>
10 10 <th><%= l(:text_scm_command_version) %></th>
11 11 </tr>
12 12 <% Redmine::Scm::Base.all.collect do |choice| %>
13 13 <% scm_class = "Repository::#{choice}".constantize %>
14 14 <% text, value = (choice.is_a?(Array) ? choice : [choice, choice]) %>
15 15 <% setting = :enabled_scm %>
16 16 <% enabled = Setting.send(setting).include?(value) %>
17 17 <tr>
18 18 <td class="scm_name">
19 19 <label>
20 20 <%= check_box_tag("settings[#{setting}][]", value, enabled, :id => nil) %>
21 21 <%= text.to_s %>
22 22 </label>
23 23 </td>
24 24 <td>
25 25 <% if enabled %>
26 26 <%=
27 27 image_tag(
28 28 (scm_class.scm_available ? 'true.png' : 'exclamation.png'),
29 29 :style => "vertical-align:bottom;"
30 30 )
31 31 %>
32 32 <%= scm_class.scm_command %>
33 33 <% end %>
34 34 </td>
35 35 <td>
36 36 <%= scm_class.scm_version_string if enabled %>
37 37 </td>
38 38 </tr>
39 39 <% end %>
40 40 </table>
41 41 <p><em class="info"><%= l(:text_scm_config) %></em></p>
42 42 </fieldset>
43 43
44 44 <div class="box tabular settings">
45 45 <p><%= setting_check_box :autofetch_changesets %></p>
46 46
47 47 <p><%= setting_check_box :sys_api_enabled,
48 48 :onclick =>
49 49 "if (this.checked) { $('#settings_sys_api_key').removeAttr('disabled'); } else { $('#settings_sys_api_key').attr('disabled', true); }" %></p>
50 50
51 51 <p><%= setting_text_field :sys_api_key,
52 52 :size => 30,
53 53 :id => 'settings_sys_api_key',
54 54 :disabled => !Setting.sys_api_enabled?,
55 55 :label => :setting_mail_handler_api_key %>
56 56 <%= link_to_function l(:label_generate_key),
57 57 "if (!$('#settings_sys_api_key').attr('disabled')) { $('#settings_sys_api_key').val(randomKey(20)) }" %>
58 58 </p>
59 59
60 60 <p><%= setting_text_field :repository_log_display_limit, :size => 6 %></p>
61 61 </div>
62 62
63 63 <fieldset class="box tabular settings">
64 64 <legend><%= l(:text_issues_ref_in_commit_messages) %></legend>
65 65 <p><%= setting_text_field :commit_ref_keywords, :size => 30 %>
66 66 <em class="info"><%= l(:text_comma_separated) %></em></p>
67 67
68 68 <p><%= setting_text_field :commit_fix_keywords, :size => 30 %>
69 69 &nbsp;<%= l(:label_applied_status) %>: <%= setting_select :commit_fix_status_id,
70 70 [["", 0]] +
71 IssueStatus.find(:all).collect{
71 IssueStatus.sorted.all.collect{
72 72 |status| [status.name, status.id.to_s]
73 73 },
74 74 :label => false %>
75 75 &nbsp;<%= l(:field_done_ratio) %>: <%= setting_select :commit_fix_done_ratio,
76 76 (0..10).to_a.collect {|r| ["#{r*10} %", "#{r*10}"] },
77 77 :blank => :label_no_change_option,
78 78 :label => false %>
79 79 <em class="info"><%= l(:text_comma_separated) %></em></p>
80 80
81 81 <p><%= setting_check_box :commit_cross_project_ref %></p>
82 82
83 83 <p><%= setting_check_box :commit_logtime_enabled,
84 84 :onclick =>
85 85 "if (this.checked) { $('#settings_commit_logtime_activity_id').removeAttr('disabled'); } else { $('#settings_commit_logtime_activity_id').attr('disabled', true); }"%></p>
86 86
87 87 <p><%= setting_select :commit_logtime_activity_id,
88 88 [[l(:label_default), 0]] +
89 89 TimeEntryActivity.shared.active.collect{|activity| [activity.name, activity.id.to_s]},
90 90 :disabled => !Setting.commit_logtime_enabled?%></p>
91 91 </fieldset>
92 92
93 93 <%= submit_tag l(:button_save) %>
94 94 <% end %>
@@ -1,67 +1,67
1 1 <% roles = Role.find_all_givable %>
2 <% projects = Project.active.find(:all, :order => 'lft') %>
2 <% projects = Project.active.all %>
3 3
4 4 <div class="splitcontentleft">
5 5 <% if @user.memberships.any? %>
6 6 <table class="list memberships">
7 7 <thead><tr>
8 8 <th><%= l(:label_project) %></th>
9 9 <th><%= l(:label_role_plural) %></th>
10 10 <th style="width:15%"></th>
11 11 <%= call_hook(:view_users_memberships_table_header, :user => @user )%>
12 12 </tr></thead>
13 13 <tbody>
14 14 <% @user.memberships.each do |membership| %>
15 15 <% next if membership.new_record? %>
16 16 <tr id="member-<%= membership.id %>" class="<%= cycle 'odd', 'even' %> class">
17 17 <td class="project">
18 18 <%= link_to_project membership.project %>
19 19 </td>
20 20 <td class="roles">
21 21 <span id="member-<%= membership.id %>-roles"><%=h membership.roles.sort.collect(&:to_s).join(', ') %></span>
22 22 <%= form_for(:membership, :remote => true,
23 23 :url => user_membership_path(@user, membership), :method => :put,
24 24 :html => {:id => "member-#{membership.id}-roles-form",
25 25 :style => 'display:none;'}) do %>
26 26 <p><% roles.each do |role| %>
27 27 <label><%= check_box_tag 'membership[role_ids][]', role.id, membership.roles.include?(role),
28 28 :disabled => membership.member_roles.detect {|mr| mr.role_id == role.id && !mr.inherited_from.nil?} %> <%=h role %></label><br />
29 29 <% end %></p>
30 30 <%= hidden_field_tag 'membership[role_ids][]', '' %>
31 31 <p><%= submit_tag l(:button_change) %>
32 32 <%= link_to_function l(:button_cancel),
33 33 "$('#member-#{membership.id}-roles').show(); $('#member-#{membership.id}-roles-form').hide(); return false;"
34 34 %></p>
35 35 <% end %>
36 36 </td>
37 37 <td class="buttons">
38 38 <%= link_to_function l(:button_edit),
39 39 "$('#member-#{membership.id}-roles').hide(); $('#member-#{membership.id}-roles-form').show(); return false;",
40 40 :class => 'icon icon-edit'
41 41 %>
42 42 <%= delete_link user_membership_path(@user, membership), :remote => true if membership.deletable? %>
43 43 </td>
44 44 <%= call_hook(:view_users_memberships_table_row, :user => @user, :membership => membership, :roles => roles, :projects => projects )%>
45 45 </tr>
46 46 <% end; reset_cycle %>
47 47 </tbody>
48 48 </table>
49 49 <% else %>
50 50 <p class="nodata"><%= l(:label_no_data) %></p>
51 51 <% end %>
52 52 </div>
53 53
54 54 <div class="splitcontentright">
55 55 <% if projects.any? %>
56 56 <fieldset><legend><%=l(:label_project_new)%></legend>
57 57 <%= form_for(:membership, :remote => true, :url => user_memberships_path(@user)) do %>
58 58 <%= select_tag 'membership[project_id]', options_for_membership_project_select(@user, projects) %>
59 59 <p><%= l(:label_role_plural) %>:
60 60 <% roles.each do |role| %>
61 61 <label><%= check_box_tag 'membership[role_ids][]', role.id %> <%=h role %></label>
62 62 <% end %></p>
63 63 <p><%= submit_tag l(:button_add) %></p>
64 64 <% end %>
65 65 </fieldset>
66 66 <% end %>
67 67 </div>
@@ -1,161 +1,160
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Customizable
21 21 def self.included(base)
22 22 base.extend ClassMethods
23 23 end
24 24
25 25 module ClassMethods
26 26 def acts_as_customizable(options = {})
27 27 return if self.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
28 28 cattr_accessor :customizable_options
29 29 self.customizable_options = options
30 30 has_many :custom_values, :as => :customized,
31 31 :include => :custom_field,
32 32 :order => "#{CustomField.table_name}.position",
33 33 :dependent => :delete_all,
34 34 :validate => false
35 35 send :include, Redmine::Acts::Customizable::InstanceMethods
36 36 validate :validate_custom_field_values
37 37 after_save :save_custom_field_values
38 38 end
39 39 end
40 40
41 41 module InstanceMethods
42 42 def self.included(base)
43 43 base.extend ClassMethods
44 44 end
45 45
46 46 def available_custom_fields
47 CustomField.find(:all, :conditions => "type = '#{self.class.name}CustomField'",
48 :order => 'position')
47 CustomField.where("type = '#{self.class.name}CustomField'").sorted.all
49 48 end
50 49
51 50 # Sets the values of the object's custom fields
52 51 # values is an array like [{'id' => 1, 'value' => 'foo'}, {'id' => 2, 'value' => 'bar'}]
53 52 def custom_fields=(values)
54 53 values_to_hash = values.inject({}) do |hash, v|
55 54 v = v.stringify_keys
56 55 if v['id'] && v.has_key?('value')
57 56 hash[v['id']] = v['value']
58 57 end
59 58 hash
60 59 end
61 60 self.custom_field_values = values_to_hash
62 61 end
63 62
64 63 # Sets the values of the object's custom fields
65 64 # values is a hash like {'1' => 'foo', 2 => 'bar'}
66 65 def custom_field_values=(values)
67 66 values = values.stringify_keys
68 67
69 68 custom_field_values.each do |custom_field_value|
70 69 key = custom_field_value.custom_field_id.to_s
71 70 if values.has_key?(key)
72 71 value = values[key]
73 72 if value.is_a?(Array)
74 73 value = value.reject(&:blank?).uniq
75 74 if value.empty?
76 75 value << ''
77 76 end
78 77 end
79 78 custom_field_value.value = value
80 79 end
81 80 end
82 81 @custom_field_values_changed = true
83 82 end
84 83
85 84 def custom_field_values
86 85 @custom_field_values ||= available_custom_fields.collect do |field|
87 86 x = CustomFieldValue.new
88 87 x.custom_field = field
89 88 x.customized = self
90 89 if field.multiple?
91 90 values = custom_values.select { |v| v.custom_field == field }
92 91 if values.empty?
93 92 values << custom_values.build(:customized => self, :custom_field => field, :value => nil)
94 93 end
95 94 x.value = values.map(&:value)
96 95 else
97 96 cv = custom_values.detect { |v| v.custom_field == field }
98 97 cv ||= custom_values.build(:customized => self, :custom_field => field, :value => nil)
99 98 x.value = cv.value
100 99 end
101 100 x
102 101 end
103 102 end
104 103
105 104 def visible_custom_field_values
106 105 custom_field_values.select(&:visible?)
107 106 end
108 107
109 108 def custom_field_values_changed?
110 109 @custom_field_values_changed == true
111 110 end
112 111
113 112 def custom_value_for(c)
114 113 field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
115 114 custom_values.detect {|v| v.custom_field_id == field_id }
116 115 end
117 116
118 117 def custom_field_value(c)
119 118 field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
120 119 custom_field_values.detect {|v| v.custom_field_id == field_id }.try(:value)
121 120 end
122 121
123 122 def validate_custom_field_values
124 123 if new_record? || custom_field_values_changed?
125 124 custom_field_values.each(&:validate_value)
126 125 end
127 126 end
128 127
129 128 def save_custom_field_values
130 129 target_custom_values = []
131 130 custom_field_values.each do |custom_field_value|
132 131 if custom_field_value.value.is_a?(Array)
133 132 custom_field_value.value.each do |v|
134 133 target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field && cv.value == v}
135 134 target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field, :value => v)
136 135 target_custom_values << target
137 136 end
138 137 else
139 138 target = custom_values.detect {|cv| cv.custom_field == custom_field_value.custom_field}
140 139 target ||= custom_values.build(:customized => self, :custom_field => custom_field_value.custom_field)
141 140 target.value = custom_field_value.value
142 141 target_custom_values << target
143 142 end
144 143 end
145 144 self.custom_values = target_custom_values
146 145 custom_values.each(&:save)
147 146 @custom_field_values_changed = false
148 147 true
149 148 end
150 149
151 150 def reset_custom_values!
152 151 @custom_field_values = nil
153 152 @custom_field_values_changed = true
154 153 end
155 154
156 155 module ClassMethods
157 156 end
158 157 end
159 158 end
160 159 end
161 160 end
@@ -1,185 +1,185
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 DefaultData
20 20 class DataAlreadyLoaded < Exception; end
21 21
22 22 module Loader
23 23 include Redmine::I18n
24 24
25 25 class << self
26 26 # Returns true if no data is already loaded in the database
27 27 # otherwise false
28 28 def no_data?
29 29 !Role.find(:first, :conditions => {:builtin => 0}) &&
30 30 !Tracker.find(:first) &&
31 31 !IssueStatus.find(:first) &&
32 32 !Enumeration.find(:first)
33 33 end
34 34
35 35 # Loads the default data
36 36 # Raises a RecordNotSaved exception if something goes wrong
37 37 def load(lang=nil)
38 38 raise DataAlreadyLoaded.new("Some configuration data is already loaded.") unless no_data?
39 39 set_language_if_valid(lang)
40 40
41 41 Role.transaction do
42 42 # Roles
43 43 manager = Role.create! :name => l(:default_role_manager),
44 44 :issues_visibility => 'all',
45 45 :position => 1
46 46 manager.permissions = manager.setable_permissions.collect {|p| p.name}
47 47 manager.save!
48 48
49 49 developer = Role.create! :name => l(:default_role_developer),
50 50 :position => 2,
51 51 :permissions => [:manage_versions,
52 52 :manage_categories,
53 53 :view_issues,
54 54 :add_issues,
55 55 :edit_issues,
56 56 :view_private_notes,
57 57 :set_notes_private,
58 58 :manage_issue_relations,
59 59 :manage_subtasks,
60 60 :add_issue_notes,
61 61 :save_queries,
62 62 :view_gantt,
63 63 :view_calendar,
64 64 :log_time,
65 65 :view_time_entries,
66 66 :comment_news,
67 67 :view_documents,
68 68 :view_wiki_pages,
69 69 :view_wiki_edits,
70 70 :edit_wiki_pages,
71 71 :delete_wiki_pages,
72 72 :add_messages,
73 73 :edit_own_messages,
74 74 :view_files,
75 75 :manage_files,
76 76 :browse_repository,
77 77 :view_changesets,
78 78 :commit_access,
79 79 :manage_related_issues]
80 80
81 81 reporter = Role.create! :name => l(:default_role_reporter),
82 82 :position => 3,
83 83 :permissions => [:view_issues,
84 84 :add_issues,
85 85 :add_issue_notes,
86 86 :save_queries,
87 87 :view_gantt,
88 88 :view_calendar,
89 89 :log_time,
90 90 :view_time_entries,
91 91 :comment_news,
92 92 :view_documents,
93 93 :view_wiki_pages,
94 94 :view_wiki_edits,
95 95 :add_messages,
96 96 :edit_own_messages,
97 97 :view_files,
98 98 :browse_repository,
99 99 :view_changesets]
100 100
101 101 Role.non_member.update_attribute :permissions, [:view_issues,
102 102 :add_issues,
103 103 :add_issue_notes,
104 104 :save_queries,
105 105 :view_gantt,
106 106 :view_calendar,
107 107 :view_time_entries,
108 108 :comment_news,
109 109 :view_documents,
110 110 :view_wiki_pages,
111 111 :view_wiki_edits,
112 112 :add_messages,
113 113 :view_files,
114 114 :browse_repository,
115 115 :view_changesets]
116 116
117 117 Role.anonymous.update_attribute :permissions, [:view_issues,
118 118 :view_gantt,
119 119 :view_calendar,
120 120 :view_time_entries,
121 121 :view_documents,
122 122 :view_wiki_pages,
123 123 :view_wiki_edits,
124 124 :view_files,
125 125 :browse_repository,
126 126 :view_changesets]
127 127
128 128 # Trackers
129 129 Tracker.create!(:name => l(:default_tracker_bug), :is_in_chlog => true, :is_in_roadmap => false, :position => 1)
130 130 Tracker.create!(:name => l(:default_tracker_feature), :is_in_chlog => true, :is_in_roadmap => true, :position => 2)
131 131 Tracker.create!(:name => l(:default_tracker_support), :is_in_chlog => false, :is_in_roadmap => false, :position => 3)
132 132
133 133 # Issue statuses
134 134 new = IssueStatus.create!(:name => l(:default_issue_status_new), :is_closed => false, :is_default => true, :position => 1)
135 135 in_progress = IssueStatus.create!(:name => l(:default_issue_status_in_progress), :is_closed => false, :is_default => false, :position => 2)
136 136 resolved = IssueStatus.create!(:name => l(:default_issue_status_resolved), :is_closed => false, :is_default => false, :position => 3)
137 137 feedback = IssueStatus.create!(:name => l(:default_issue_status_feedback), :is_closed => false, :is_default => false, :position => 4)
138 138 closed = IssueStatus.create!(:name => l(:default_issue_status_closed), :is_closed => true, :is_default => false, :position => 5)
139 139 rejected = IssueStatus.create!(:name => l(:default_issue_status_rejected), :is_closed => true, :is_default => false, :position => 6)
140 140
141 141 # Workflow
142 Tracker.find(:all).each { |t|
143 IssueStatus.find(:all).each { |os|
144 IssueStatus.find(:all).each { |ns|
142 Tracker.all.each { |t|
143 IssueStatus.all.each { |os|
144 IssueStatus.all.each { |ns|
145 145 WorkflowTransition.create!(:tracker_id => t.id, :role_id => manager.id, :old_status_id => os.id, :new_status_id => ns.id) unless os == ns
146 146 }
147 147 }
148 148 }
149 149
150 Tracker.find(:all).each { |t|
150 Tracker.all.each { |t|
151 151 [new, in_progress, resolved, feedback].each { |os|
152 152 [in_progress, resolved, feedback, closed].each { |ns|
153 153 WorkflowTransition.create!(:tracker_id => t.id, :role_id => developer.id, :old_status_id => os.id, :new_status_id => ns.id) unless os == ns
154 154 }
155 155 }
156 156 }
157 157
158 Tracker.find(:all).each { |t|
158 Tracker.all.each { |t|
159 159 [new, in_progress, resolved, feedback].each { |os|
160 160 [closed].each { |ns|
161 161 WorkflowTransition.create!(:tracker_id => t.id, :role_id => reporter.id, :old_status_id => os.id, :new_status_id => ns.id) unless os == ns
162 162 }
163 163 }
164 164 WorkflowTransition.create!(:tracker_id => t.id, :role_id => reporter.id, :old_status_id => resolved.id, :new_status_id => feedback.id)
165 165 }
166 166
167 167 # Enumerations
168 168 IssuePriority.create!(:name => l(:default_priority_low), :position => 1)
169 169 IssuePriority.create!(:name => l(:default_priority_normal), :position => 2, :is_default => true)
170 170 IssuePriority.create!(:name => l(:default_priority_high), :position => 3)
171 171 IssuePriority.create!(:name => l(:default_priority_urgent), :position => 4)
172 172 IssuePriority.create!(:name => l(:default_priority_immediate), :position => 5)
173 173
174 174 DocumentCategory.create!(:name => l(:default_doc_category_user), :position => 1)
175 175 DocumentCategory.create!(:name => l(:default_doc_category_tech), :position => 2)
176 176
177 177 TimeEntryActivity.create!(:name => l(:default_activity_design), :position => 1)
178 178 TimeEntryActivity.create!(:name => l(:default_activity_development), :position => 2)
179 179 end
180 180 true
181 181 end
182 182 end
183 183 end
184 184 end
185 185 end
@@ -1,164 +1,164
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 Helpers
20 20 class TimeReport
21 21 attr_reader :criteria, :columns, :from, :to, :hours, :total_hours, :periods
22 22
23 23 def initialize(project, issue, criteria, columns, from, to)
24 24 @project = project
25 25 @issue = issue
26 26
27 27 @criteria = criteria || []
28 28 @criteria = @criteria.select{|criteria| available_criteria.has_key? criteria}
29 29 @criteria.uniq!
30 30 @criteria = @criteria[0,3]
31 31
32 32 @columns = (columns && %w(year month week day).include?(columns)) ? columns : 'month'
33 33 @from = from
34 34 @to = to
35 35
36 36 run
37 37 end
38 38
39 39 def available_criteria
40 40 @available_criteria || load_available_criteria
41 41 end
42 42
43 43 private
44 44
45 45 def run
46 46 unless @criteria.empty?
47 47 scope = TimeEntry.visible.spent_between(@from, @to)
48 48 if @issue
49 49 scope = scope.on_issue(@issue)
50 50 elsif @project
51 51 scope = scope.on_project(@project, Setting.display_subprojects_issues?)
52 52 end
53 53 time_columns = %w(tyear tmonth tweek spent_on)
54 54 @hours = []
55 55 scope.sum(:hours, :include => :issue, :group => @criteria.collect{|criteria| @available_criteria[criteria][:sql]} + time_columns).each do |hash, hours|
56 56 h = {'hours' => hours}
57 57 (@criteria + time_columns).each_with_index do |name, i|
58 58 h[name] = hash[i]
59 59 end
60 60 @hours << h
61 61 end
62 62
63 63 @hours.each do |row|
64 64 case @columns
65 65 when 'year'
66 66 row['year'] = row['tyear']
67 67 when 'month'
68 68 row['month'] = "#{row['tyear']}-#{row['tmonth']}"
69 69 when 'week'
70 70 row['week'] = "#{row['tyear']}-#{row['tweek']}"
71 71 when 'day'
72 72 row['day'] = "#{row['spent_on']}"
73 73 end
74 74 end
75 75
76 76 if @from.nil?
77 77 min = @hours.collect {|row| row['spent_on']}.min
78 78 @from = min ? min.to_date : Date.today
79 79 end
80 80
81 81 if @to.nil?
82 82 max = @hours.collect {|row| row['spent_on']}.max
83 83 @to = max ? max.to_date : Date.today
84 84 end
85 85
86 86 @total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f}
87 87
88 88 @periods = []
89 89 # Date#at_beginning_of_ not supported in Rails 1.2.x
90 90 date_from = @from.to_time
91 91 # 100 columns max
92 92 while date_from <= @to.to_time && @periods.length < 100
93 93 case @columns
94 94 when 'year'
95 95 @periods << "#{date_from.year}"
96 96 date_from = (date_from + 1.year).at_beginning_of_year
97 97 when 'month'
98 98 @periods << "#{date_from.year}-#{date_from.month}"
99 99 date_from = (date_from + 1.month).at_beginning_of_month
100 100 when 'week'
101 101 @periods << "#{date_from.year}-#{date_from.to_date.cweek}"
102 102 date_from = (date_from + 7.day).at_beginning_of_week
103 103 when 'day'
104 104 @periods << "#{date_from.to_date}"
105 105 date_from = date_from + 1.day
106 106 end
107 107 end
108 108 end
109 109 end
110 110
111 111 def load_available_criteria
112 112 @available_criteria = { 'project' => {:sql => "#{TimeEntry.table_name}.project_id",
113 113 :klass => Project,
114 114 :label => :label_project},
115 115 'status' => {:sql => "#{Issue.table_name}.status_id",
116 116 :klass => IssueStatus,
117 117 :label => :field_status},
118 118 'version' => {:sql => "#{Issue.table_name}.fixed_version_id",
119 119 :klass => Version,
120 120 :label => :label_version},
121 121 'category' => {:sql => "#{Issue.table_name}.category_id",
122 122 :klass => IssueCategory,
123 123 :label => :field_category},
124 124 'member' => {:sql => "#{TimeEntry.table_name}.user_id",
125 125 :klass => User,
126 126 :label => :label_member},
127 127 'tracker' => {:sql => "#{Issue.table_name}.tracker_id",
128 128 :klass => Tracker,
129 129 :label => :label_tracker},
130 130 'activity' => {:sql => "#{TimeEntry.table_name}.activity_id",
131 131 :klass => TimeEntryActivity,
132 132 :label => :label_activity},
133 133 'issue' => {:sql => "#{TimeEntry.table_name}.issue_id",
134 134 :klass => Issue,
135 135 :label => :label_issue}
136 136 }
137 137
138 138 # Add list and boolean custom fields as available criteria
139 139 custom_fields = (@project.nil? ? IssueCustomField.for_all : @project.all_issue_custom_fields)
140 140 custom_fields.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
141 141 @available_criteria["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Issue' AND c.customized_id = #{Issue.table_name}.id ORDER BY c.value LIMIT 1)",
142 142 :format => cf.field_format,
143 143 :label => cf.name}
144 144 end if @project
145 145
146 146 # Add list and boolean time entry custom fields
147 TimeEntryCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
147 TimeEntryCustomField.all.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
148 148 @available_criteria["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'TimeEntry' AND c.customized_id = #{TimeEntry.table_name}.id ORDER BY c.value LIMIT 1)",
149 149 :format => cf.field_format,
150 150 :label => cf.name}
151 151 end
152 152
153 153 # Add list and boolean time entry activity custom fields
154 TimeEntryActivityCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
154 TimeEntryActivityCustomField.all.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
155 155 @available_criteria["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Enumeration' AND c.customized_id = #{TimeEntry.table_name}.activity_id ORDER BY c.value LIMIT 1)",
156 156 :format => cf.field_format,
157 157 :label => cf.name}
158 158 end
159 159
160 160 @available_criteria
161 161 end
162 162 end
163 163 end
164 164 end
@@ -1,511 +1,511
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 desc 'Mantis migration script'
19 19
20 20 require 'active_record'
21 21 require 'iconv'
22 22 require 'pp'
23 23
24 24 namespace :redmine do
25 25 task :migrate_from_mantis => :environment do
26 26
27 27 module MantisMigrate
28 28
29 29 DEFAULT_STATUS = IssueStatus.default
30 30 assigned_status = IssueStatus.find_by_position(2)
31 31 resolved_status = IssueStatus.find_by_position(3)
32 32 feedback_status = IssueStatus.find_by_position(4)
33 33 closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
34 34 STATUS_MAPPING = {10 => DEFAULT_STATUS, # new
35 35 20 => feedback_status, # feedback
36 36 30 => DEFAULT_STATUS, # acknowledged
37 37 40 => DEFAULT_STATUS, # confirmed
38 38 50 => assigned_status, # assigned
39 39 80 => resolved_status, # resolved
40 40 90 => closed_status # closed
41 41 }
42 42
43 43 priorities = IssuePriority.all
44 44 DEFAULT_PRIORITY = priorities[2]
45 45 PRIORITY_MAPPING = {10 => priorities[1], # none
46 46 20 => priorities[1], # low
47 47 30 => priorities[2], # normal
48 48 40 => priorities[3], # high
49 49 50 => priorities[4], # urgent
50 50 60 => priorities[5] # immediate
51 51 }
52 52
53 53 TRACKER_BUG = Tracker.find_by_position(1)
54 54 TRACKER_FEATURE = Tracker.find_by_position(2)
55 55
56 roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
56 roles = Role.where(:builtin => 0).order('position ASC').all
57 57 manager_role = roles[0]
58 58 developer_role = roles[1]
59 59 DEFAULT_ROLE = roles.last
60 60 ROLE_MAPPING = {10 => DEFAULT_ROLE, # viewer
61 61 25 => DEFAULT_ROLE, # reporter
62 62 40 => DEFAULT_ROLE, # updater
63 63 55 => developer_role, # developer
64 64 70 => manager_role, # manager
65 65 90 => manager_role # administrator
66 66 }
67 67
68 68 CUSTOM_FIELD_TYPE_MAPPING = {0 => 'string', # String
69 69 1 => 'int', # Numeric
70 70 2 => 'int', # Float
71 71 3 => 'list', # Enumeration
72 72 4 => 'string', # Email
73 73 5 => 'bool', # Checkbox
74 74 6 => 'list', # List
75 75 7 => 'list', # Multiselection list
76 76 8 => 'date', # Date
77 77 }
78 78
79 79 RELATION_TYPE_MAPPING = {1 => IssueRelation::TYPE_RELATES, # related to
80 80 2 => IssueRelation::TYPE_RELATES, # parent of
81 81 3 => IssueRelation::TYPE_RELATES, # child of
82 82 0 => IssueRelation::TYPE_DUPLICATES, # duplicate of
83 83 4 => IssueRelation::TYPE_DUPLICATES # has duplicate
84 84 }
85 85
86 86 class MantisUser < ActiveRecord::Base
87 87 self.table_name = :mantis_user_table
88 88
89 89 def firstname
90 90 @firstname = realname.blank? ? username : realname.split.first[0..29]
91 91 @firstname
92 92 end
93 93
94 94 def lastname
95 95 @lastname = realname.blank? ? '-' : realname.split[1..-1].join(' ')[0..29]
96 96 @lastname = '-' if @lastname.blank?
97 97 @lastname
98 98 end
99 99
100 100 def email
101 101 if read_attribute(:email).match(/^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i) &&
102 102 !User.find_by_mail(read_attribute(:email))
103 103 @email = read_attribute(:email)
104 104 else
105 105 @email = "#{username}@foo.bar"
106 106 end
107 107 end
108 108
109 109 def username
110 110 read_attribute(:username)[0..29].gsub(/[^a-zA-Z0-9_\-@\.]/, '-')
111 111 end
112 112 end
113 113
114 114 class MantisProject < ActiveRecord::Base
115 115 self.table_name = :mantis_project_table
116 116 has_many :versions, :class_name => "MantisVersion", :foreign_key => :project_id
117 117 has_many :categories, :class_name => "MantisCategory", :foreign_key => :project_id
118 118 has_many :news, :class_name => "MantisNews", :foreign_key => :project_id
119 119 has_many :members, :class_name => "MantisProjectUser", :foreign_key => :project_id
120 120
121 121 def identifier
122 122 read_attribute(:name).gsub(/[^a-z0-9\-]+/, '-').slice(0, Project::IDENTIFIER_MAX_LENGTH)
123 123 end
124 124 end
125 125
126 126 class MantisVersion < ActiveRecord::Base
127 127 self.table_name = :mantis_project_version_table
128 128
129 129 def version
130 130 read_attribute(:version)[0..29]
131 131 end
132 132
133 133 def description
134 134 read_attribute(:description)[0..254]
135 135 end
136 136 end
137 137
138 138 class MantisCategory < ActiveRecord::Base
139 139 self.table_name = :mantis_project_category_table
140 140 end
141 141
142 142 class MantisProjectUser < ActiveRecord::Base
143 143 self.table_name = :mantis_project_user_list_table
144 144 end
145 145
146 146 class MantisBug < ActiveRecord::Base
147 147 self.table_name = :mantis_bug_table
148 148 belongs_to :bug_text, :class_name => "MantisBugText", :foreign_key => :bug_text_id
149 149 has_many :bug_notes, :class_name => "MantisBugNote", :foreign_key => :bug_id
150 150 has_many :bug_files, :class_name => "MantisBugFile", :foreign_key => :bug_id
151 151 has_many :bug_monitors, :class_name => "MantisBugMonitor", :foreign_key => :bug_id
152 152 end
153 153
154 154 class MantisBugText < ActiveRecord::Base
155 155 self.table_name = :mantis_bug_text_table
156 156
157 157 # Adds Mantis steps_to_reproduce and additional_information fields
158 158 # to description if any
159 159 def full_description
160 160 full_description = description
161 161 full_description += "\n\n*Steps to reproduce:*\n\n#{steps_to_reproduce}" unless steps_to_reproduce.blank?
162 162 full_description += "\n\n*Additional information:*\n\n#{additional_information}" unless additional_information.blank?
163 163 full_description
164 164 end
165 165 end
166 166
167 167 class MantisBugNote < ActiveRecord::Base
168 168 self.table_name = :mantis_bugnote_table
169 169 belongs_to :bug, :class_name => "MantisBug", :foreign_key => :bug_id
170 170 belongs_to :bug_note_text, :class_name => "MantisBugNoteText", :foreign_key => :bugnote_text_id
171 171 end
172 172
173 173 class MantisBugNoteText < ActiveRecord::Base
174 174 self.table_name = :mantis_bugnote_text_table
175 175 end
176 176
177 177 class MantisBugFile < ActiveRecord::Base
178 178 self.table_name = :mantis_bug_file_table
179 179
180 180 def size
181 181 filesize
182 182 end
183 183
184 184 def original_filename
185 185 MantisMigrate.encode(filename)
186 186 end
187 187
188 188 def content_type
189 189 file_type
190 190 end
191 191
192 192 def read(*args)
193 193 if @read_finished
194 194 nil
195 195 else
196 196 @read_finished = true
197 197 content
198 198 end
199 199 end
200 200 end
201 201
202 202 class MantisBugRelationship < ActiveRecord::Base
203 203 self.table_name = :mantis_bug_relationship_table
204 204 end
205 205
206 206 class MantisBugMonitor < ActiveRecord::Base
207 207 self.table_name = :mantis_bug_monitor_table
208 208 end
209 209
210 210 class MantisNews < ActiveRecord::Base
211 211 self.table_name = :mantis_news_table
212 212 end
213 213
214 214 class MantisCustomField < ActiveRecord::Base
215 215 self.table_name = :mantis_custom_field_table
216 216 set_inheritance_column :none
217 217 has_many :values, :class_name => "MantisCustomFieldString", :foreign_key => :field_id
218 218 has_many :projects, :class_name => "MantisCustomFieldProject", :foreign_key => :field_id
219 219
220 220 def format
221 221 read_attribute :type
222 222 end
223 223
224 224 def name
225 225 read_attribute(:name)[0..29]
226 226 end
227 227 end
228 228
229 229 class MantisCustomFieldProject < ActiveRecord::Base
230 230 self.table_name = :mantis_custom_field_project_table
231 231 end
232 232
233 233 class MantisCustomFieldString < ActiveRecord::Base
234 234 self.table_name = :mantis_custom_field_string_table
235 235 end
236 236
237 237 def self.migrate
238 238
239 239 # Users
240 240 print "Migrating users"
241 241 User.delete_all "login <> 'admin'"
242 242 users_map = {}
243 243 users_migrated = 0
244 MantisUser.find(:all).each do |user|
244 MantisUser.all.each do |user|
245 245 u = User.new :firstname => encode(user.firstname),
246 246 :lastname => encode(user.lastname),
247 247 :mail => user.email,
248 248 :last_login_on => user.last_visit
249 249 u.login = user.username
250 250 u.password = 'mantis'
251 251 u.status = User::STATUS_LOCKED if user.enabled != 1
252 252 u.admin = true if user.access_level == 90
253 253 next unless u.save!
254 254 users_migrated += 1
255 255 users_map[user.id] = u.id
256 256 print '.'
257 257 end
258 258 puts
259 259
260 260 # Projects
261 261 print "Migrating projects"
262 262 Project.destroy_all
263 263 projects_map = {}
264 264 versions_map = {}
265 265 categories_map = {}
266 MantisProject.find(:all).each do |project|
266 MantisProject.all.each do |project|
267 267 p = Project.new :name => encode(project.name),
268 268 :description => encode(project.description)
269 269 p.identifier = project.identifier
270 270 next unless p.save
271 271 projects_map[project.id] = p.id
272 272 p.enabled_module_names = ['issue_tracking', 'news', 'wiki']
273 273 p.trackers << TRACKER_BUG unless p.trackers.include?(TRACKER_BUG)
274 274 p.trackers << TRACKER_FEATURE unless p.trackers.include?(TRACKER_FEATURE)
275 275 print '.'
276 276
277 277 # Project members
278 278 project.members.each do |member|
279 279 m = Member.new :user => User.find_by_id(users_map[member.user_id]),
280 280 :roles => [ROLE_MAPPING[member.access_level] || DEFAULT_ROLE]
281 281 m.project = p
282 282 m.save
283 283 end
284 284
285 285 # Project versions
286 286 project.versions.each do |version|
287 287 v = Version.new :name => encode(version.version),
288 288 :description => encode(version.description),
289 289 :effective_date => (version.date_order ? version.date_order.to_date : nil)
290 290 v.project = p
291 291 v.save
292 292 versions_map[version.id] = v.id
293 293 end
294 294
295 295 # Project categories
296 296 project.categories.each do |category|
297 297 g = IssueCategory.new :name => category.category[0,30]
298 298 g.project = p
299 299 g.save
300 300 categories_map[category.category] = g.id
301 301 end
302 302 end
303 303 puts
304 304
305 305 # Bugs
306 306 print "Migrating bugs"
307 307 Issue.destroy_all
308 308 issues_map = {}
309 309 keep_bug_ids = (Issue.count == 0)
310 310 MantisBug.find_each(:batch_size => 200) do |bug|
311 311 next unless projects_map[bug.project_id] && users_map[bug.reporter_id]
312 312 i = Issue.new :project_id => projects_map[bug.project_id],
313 313 :subject => encode(bug.summary),
314 314 :description => encode(bug.bug_text.full_description),
315 315 :priority => PRIORITY_MAPPING[bug.priority] || DEFAULT_PRIORITY,
316 316 :created_on => bug.date_submitted,
317 317 :updated_on => bug.last_updated
318 318 i.author = User.find_by_id(users_map[bug.reporter_id])
319 319 i.category = IssueCategory.find_by_project_id_and_name(i.project_id, bug.category[0,30]) unless bug.category.blank?
320 320 i.fixed_version = Version.find_by_project_id_and_name(i.project_id, bug.fixed_in_version) unless bug.fixed_in_version.blank?
321 321 i.status = STATUS_MAPPING[bug.status] || DEFAULT_STATUS
322 322 i.tracker = (bug.severity == 10 ? TRACKER_FEATURE : TRACKER_BUG)
323 323 i.id = bug.id if keep_bug_ids
324 324 next unless i.save
325 325 issues_map[bug.id] = i.id
326 326 print '.'
327 327 STDOUT.flush
328 328
329 329 # Assignee
330 330 # Redmine checks that the assignee is a project member
331 331 if (bug.handler_id && users_map[bug.handler_id])
332 332 i.assigned_to = User.find_by_id(users_map[bug.handler_id])
333 333 i.save(:validate => false)
334 334 end
335 335
336 336 # Bug notes
337 337 bug.bug_notes.each do |note|
338 338 next unless users_map[note.reporter_id]
339 339 n = Journal.new :notes => encode(note.bug_note_text.note),
340 340 :created_on => note.date_submitted
341 341 n.user = User.find_by_id(users_map[note.reporter_id])
342 342 n.journalized = i
343 343 n.save
344 344 end
345 345
346 346 # Bug files
347 347 bug.bug_files.each do |file|
348 348 a = Attachment.new :created_on => file.date_added
349 349 a.file = file
350 350 a.author = User.find :first
351 351 a.container = i
352 352 a.save
353 353 end
354 354
355 355 # Bug monitors
356 356 bug.bug_monitors.each do |monitor|
357 357 next unless users_map[monitor.user_id]
358 358 i.add_watcher(User.find_by_id(users_map[monitor.user_id]))
359 359 end
360 360 end
361 361
362 362 # update issue id sequence if needed (postgresql)
363 363 Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
364 364 puts
365 365
366 366 # Bug relationships
367 367 print "Migrating bug relations"
368 MantisBugRelationship.find(:all).each do |relation|
368 MantisBugRelationship.all.each do |relation|
369 369 next unless issues_map[relation.source_bug_id] && issues_map[relation.destination_bug_id]
370 370 r = IssueRelation.new :relation_type => RELATION_TYPE_MAPPING[relation.relationship_type]
371 371 r.issue_from = Issue.find_by_id(issues_map[relation.source_bug_id])
372 372 r.issue_to = Issue.find_by_id(issues_map[relation.destination_bug_id])
373 373 pp r unless r.save
374 374 print '.'
375 375 STDOUT.flush
376 376 end
377 377 puts
378 378
379 379 # News
380 380 print "Migrating news"
381 381 News.destroy_all
382 MantisNews.find(:all, :conditions => 'project_id > 0').each do |news|
382 MantisNews.where('project_id > 0').all.each do |news|
383 383 next unless projects_map[news.project_id]
384 384 n = News.new :project_id => projects_map[news.project_id],
385 385 :title => encode(news.headline[0..59]),
386 386 :description => encode(news.body),
387 387 :created_on => news.date_posted
388 388 n.author = User.find_by_id(users_map[news.poster_id])
389 389 n.save
390 390 print '.'
391 391 STDOUT.flush
392 392 end
393 393 puts
394 394
395 395 # Custom fields
396 396 print "Migrating custom fields"
397 397 IssueCustomField.destroy_all
398 MantisCustomField.find(:all).each do |field|
398 MantisCustomField.all.each do |field|
399 399 f = IssueCustomField.new :name => field.name[0..29],
400 400 :field_format => CUSTOM_FIELD_TYPE_MAPPING[field.format],
401 401 :min_length => field.length_min,
402 402 :max_length => field.length_max,
403 403 :regexp => field.valid_regexp,
404 404 :possible_values => field.possible_values.split('|'),
405 405 :is_required => field.require_report?
406 406 next unless f.save
407 407 print '.'
408 408 STDOUT.flush
409 409 # Trackers association
410 f.trackers = Tracker.find :all
410 f.trackers = Tracker.all
411 411
412 412 # Projects association
413 413 field.projects.each do |project|
414 414 f.projects << Project.find_by_id(projects_map[project.project_id]) if projects_map[project.project_id]
415 415 end
416 416
417 417 # Values
418 418 field.values.each do |value|
419 419 v = CustomValue.new :custom_field_id => f.id,
420 420 :value => value.value
421 421 v.customized = Issue.find_by_id(issues_map[value.bug_id]) if issues_map[value.bug_id]
422 422 v.save
423 423 end unless f.new_record?
424 424 end
425 425 puts
426 426
427 427 puts
428 428 puts "Users: #{users_migrated}/#{MantisUser.count}"
429 429 puts "Projects: #{Project.count}/#{MantisProject.count}"
430 430 puts "Memberships: #{Member.count}/#{MantisProjectUser.count}"
431 431 puts "Versions: #{Version.count}/#{MantisVersion.count}"
432 432 puts "Categories: #{IssueCategory.count}/#{MantisCategory.count}"
433 433 puts "Bugs: #{Issue.count}/#{MantisBug.count}"
434 434 puts "Bug notes: #{Journal.count}/#{MantisBugNote.count}"
435 435 puts "Bug files: #{Attachment.count}/#{MantisBugFile.count}"
436 436 puts "Bug relations: #{IssueRelation.count}/#{MantisBugRelationship.count}"
437 437 puts "Bug monitors: #{Watcher.count}/#{MantisBugMonitor.count}"
438 438 puts "News: #{News.count}/#{MantisNews.count}"
439 439 puts "Custom fields: #{IssueCustomField.count}/#{MantisCustomField.count}"
440 440 end
441 441
442 442 def self.encoding(charset)
443 443 @ic = Iconv.new('UTF-8', charset)
444 444 rescue Iconv::InvalidEncoding
445 445 return false
446 446 end
447 447
448 448 def self.establish_connection(params)
449 449 constants.each do |const|
450 450 klass = const_get(const)
451 451 next unless klass.respond_to? 'establish_connection'
452 452 klass.establish_connection params
453 453 end
454 454 end
455 455
456 456 def self.encode(text)
457 457 @ic.iconv text
458 458 rescue
459 459 text
460 460 end
461 461 end
462 462
463 463 puts
464 464 if Redmine::DefaultData::Loader.no_data?
465 465 puts "Redmine configuration need to be loaded before importing data."
466 466 puts "Please, run this first:"
467 467 puts
468 468 puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
469 469 exit
470 470 end
471 471
472 472 puts "WARNING: Your Redmine data will be deleted during this process."
473 473 print "Are you sure you want to continue ? [y/N] "
474 474 STDOUT.flush
475 475 break unless STDIN.gets.match(/^y$/i)
476 476
477 477 # Default Mantis database settings
478 478 db_params = {:adapter => 'mysql2',
479 479 :database => 'bugtracker',
480 480 :host => 'localhost',
481 481 :username => 'root',
482 482 :password => '' }
483 483
484 484 puts
485 485 puts "Please enter settings for your Mantis database"
486 486 [:adapter, :host, :database, :username, :password].each do |param|
487 487 print "#{param} [#{db_params[param]}]: "
488 488 value = STDIN.gets.chomp!
489 489 db_params[param] = value unless value.blank?
490 490 end
491 491
492 492 while true
493 493 print "encoding [UTF-8]: "
494 494 STDOUT.flush
495 495 encoding = STDIN.gets.chomp!
496 496 encoding = 'UTF-8' if encoding.blank?
497 497 break if MantisMigrate.encoding encoding
498 498 puts "Invalid encoding!"
499 499 end
500 500 puts
501 501
502 502 # Make sure bugs can refer bugs in other projects
503 503 Setting.cross_project_issue_relations = 1 if Setting.respond_to? 'cross_project_issue_relations'
504 504
505 505 # Turn off email notifications
506 506 Setting.notified_events = []
507 507
508 508 MantisMigrate.establish_connection db_params
509 509 MantisMigrate.migrate
510 510 end
511 511 end
@@ -1,772 +1,772
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 'active_record'
19 19 require 'iconv'
20 20 require 'pp'
21 21
22 22 namespace :redmine do
23 23 desc 'Trac migration script'
24 24 task :migrate_from_trac => :environment do
25 25
26 26 module TracMigrate
27 27 TICKET_MAP = []
28 28
29 29 DEFAULT_STATUS = IssueStatus.default
30 30 assigned_status = IssueStatus.find_by_position(2)
31 31 resolved_status = IssueStatus.find_by_position(3)
32 32 feedback_status = IssueStatus.find_by_position(4)
33 33 closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
34 34 STATUS_MAPPING = {'new' => DEFAULT_STATUS,
35 35 'reopened' => feedback_status,
36 36 'assigned' => assigned_status,
37 37 'closed' => closed_status
38 38 }
39 39
40 40 priorities = IssuePriority.all
41 41 DEFAULT_PRIORITY = priorities[0]
42 42 PRIORITY_MAPPING = {'lowest' => priorities[0],
43 43 'low' => priorities[0],
44 44 'normal' => priorities[1],
45 45 'high' => priorities[2],
46 46 'highest' => priorities[3],
47 47 # ---
48 48 'trivial' => priorities[0],
49 49 'minor' => priorities[1],
50 50 'major' => priorities[2],
51 51 'critical' => priorities[3],
52 52 'blocker' => priorities[4]
53 53 }
54 54
55 55 TRACKER_BUG = Tracker.find_by_position(1)
56 56 TRACKER_FEATURE = Tracker.find_by_position(2)
57 57 DEFAULT_TRACKER = TRACKER_BUG
58 58 TRACKER_MAPPING = {'defect' => TRACKER_BUG,
59 59 'enhancement' => TRACKER_FEATURE,
60 60 'task' => TRACKER_FEATURE,
61 61 'patch' =>TRACKER_FEATURE
62 62 }
63 63
64 roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
64 roles = Role.where(:builtin => 0).order('position ASC').all
65 65 manager_role = roles[0]
66 66 developer_role = roles[1]
67 67 DEFAULT_ROLE = roles.last
68 68 ROLE_MAPPING = {'admin' => manager_role,
69 69 'developer' => developer_role
70 70 }
71 71
72 72 class ::Time
73 73 class << self
74 74 alias :real_now :now
75 75 def now
76 76 real_now - @fake_diff.to_i
77 77 end
78 78 def fake(time)
79 79 @fake_diff = real_now - time
80 80 res = yield
81 81 @fake_diff = 0
82 82 res
83 83 end
84 84 end
85 85 end
86 86
87 87 class TracComponent < ActiveRecord::Base
88 88 self.table_name = :component
89 89 end
90 90
91 91 class TracMilestone < ActiveRecord::Base
92 92 self.table_name = :milestone
93 93 # If this attribute is set a milestone has a defined target timepoint
94 94 def due
95 95 if read_attribute(:due) && read_attribute(:due) > 0
96 96 Time.at(read_attribute(:due)).to_date
97 97 else
98 98 nil
99 99 end
100 100 end
101 101 # This is the real timepoint at which the milestone has finished.
102 102 def completed
103 103 if read_attribute(:completed) && read_attribute(:completed) > 0
104 104 Time.at(read_attribute(:completed)).to_date
105 105 else
106 106 nil
107 107 end
108 108 end
109 109
110 110 def description
111 111 # Attribute is named descr in Trac v0.8.x
112 112 has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
113 113 end
114 114 end
115 115
116 116 class TracTicketCustom < ActiveRecord::Base
117 117 self.table_name = :ticket_custom
118 118 end
119 119
120 120 class TracAttachment < ActiveRecord::Base
121 121 self.table_name = :attachment
122 122 set_inheritance_column :none
123 123
124 124 def time; Time.at(read_attribute(:time)) end
125 125
126 126 def original_filename
127 127 filename
128 128 end
129 129
130 130 def content_type
131 131 ''
132 132 end
133 133
134 134 def exist?
135 135 File.file? trac_fullpath
136 136 end
137 137
138 138 def open
139 139 File.open("#{trac_fullpath}", 'rb') {|f|
140 140 @file = f
141 141 yield self
142 142 }
143 143 end
144 144
145 145 def read(*args)
146 146 @file.read(*args)
147 147 end
148 148
149 149 def description
150 150 read_attribute(:description).to_s.slice(0,255)
151 151 end
152 152
153 153 private
154 154 def trac_fullpath
155 155 attachment_type = read_attribute(:type)
156 156 trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
157 157 "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
158 158 end
159 159 end
160 160
161 161 class TracTicket < ActiveRecord::Base
162 162 self.table_name = :ticket
163 163 set_inheritance_column :none
164 164
165 165 # ticket changes: only migrate status changes and comments
166 166 has_many :ticket_changes, :class_name => "TracTicketChange", :foreign_key => :ticket
167 167 has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
168 168
169 169 def attachments
170 170 TracMigrate::TracAttachment.all(:conditions => ["type = 'ticket' AND id = ?", self.id.to_s])
171 171 end
172 172
173 173 def ticket_type
174 174 read_attribute(:type)
175 175 end
176 176
177 177 def summary
178 178 read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
179 179 end
180 180
181 181 def description
182 182 read_attribute(:description).blank? ? summary : read_attribute(:description)
183 183 end
184 184
185 185 def time; Time.at(read_attribute(:time)) end
186 186 def changetime; Time.at(read_attribute(:changetime)) end
187 187 end
188 188
189 189 class TracTicketChange < ActiveRecord::Base
190 190 self.table_name = :ticket_change
191 191
192 192 def self.columns
193 193 # Hides Trac field 'field' to prevent clash with AR field_changed? method (Rails 3.0)
194 194 super.select {|column| column.name.to_s != 'field'}
195 195 end
196 196
197 197 def time; Time.at(read_attribute(:time)) end
198 198 end
199 199
200 200 TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
201 201 TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
202 202 TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
203 203 TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
204 204 TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
205 205 WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
206 206 CamelCase TitleIndex)
207 207
208 208 class TracWikiPage < ActiveRecord::Base
209 209 self.table_name = :wiki
210 210 set_primary_key :name
211 211
212 212 def self.columns
213 213 # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
214 214 super.select {|column| column.name.to_s != 'readonly'}
215 215 end
216 216
217 217 def attachments
218 218 TracMigrate::TracAttachment.all(:conditions => ["type = 'wiki' AND id = ?", self.id.to_s])
219 219 end
220 220
221 221 def time; Time.at(read_attribute(:time)) end
222 222 end
223 223
224 224 class TracPermission < ActiveRecord::Base
225 225 self.table_name = :permission
226 226 end
227 227
228 228 class TracSessionAttribute < ActiveRecord::Base
229 229 self.table_name = :session_attribute
230 230 end
231 231
232 232 def self.find_or_create_user(username, project_member = false)
233 233 return User.anonymous if username.blank?
234 234
235 235 u = User.find_by_login(username)
236 236 if !u
237 237 # Create a new user if not found
238 238 mail = username[0, User::MAIL_LENGTH_LIMIT]
239 239 if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
240 240 mail = mail_attr.value
241 241 end
242 242 mail = "#{mail}@foo.bar" unless mail.include?("@")
243 243
244 244 name = username
245 245 if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
246 246 name = name_attr.value
247 247 end
248 248 name =~ (/(.*)(\s+\w+)?/)
249 249 fn = $1.strip
250 250 ln = ($2 || '-').strip
251 251
252 252 u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
253 253 :firstname => fn[0, limit_for(User, 'firstname')],
254 254 :lastname => ln[0, limit_for(User, 'lastname')]
255 255
256 256 u.login = username[0, User::LOGIN_LENGTH_LIMIT].gsub(/[^a-z0-9_\-@\.]/i, '-')
257 257 u.password = 'trac'
258 258 u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
259 259 # finally, a default user is used if the new user is not valid
260 260 u = User.find(:first) unless u.save
261 261 end
262 262 # Make sure he is a member of the project
263 263 if project_member && !u.member_of?(@target_project)
264 264 role = DEFAULT_ROLE
265 265 if u.admin
266 266 role = ROLE_MAPPING['admin']
267 267 elsif TracPermission.find_by_username_and_action(username, 'developer')
268 268 role = ROLE_MAPPING['developer']
269 269 end
270 270 Member.create(:user => u, :project => @target_project, :roles => [role])
271 271 u.reload
272 272 end
273 273 u
274 274 end
275 275
276 276 # Basic wiki syntax conversion
277 277 def self.convert_wiki_text(text)
278 278 # Titles
279 279 text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
280 280 # External Links
281 281 text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
282 282 # Ticket links:
283 283 # [ticket:234 Text],[ticket:234 This is a test]
284 284 text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
285 285 # ticket:1234
286 286 # #1 is working cause Redmine uses the same syntax.
287 287 text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
288 288 # Milestone links:
289 289 # [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
290 290 # The text "Milestone 0.1.0 (Mercury)" is not converted,
291 291 # cause Redmine's wiki does not support this.
292 292 text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
293 293 # [milestone:"0.1.0 Mercury"]
294 294 text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
295 295 text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
296 296 # milestone:0.1.0
297 297 text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
298 298 text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
299 299 # Internal Links
300 300 text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
301 301 text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
302 302 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
303 303 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
304 304 text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
305 305 text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
306 306
307 307 # Links to pages UsingJustWikiCaps
308 308 text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
309 309 # Normalize things that were supposed to not be links
310 310 # like !NotALink
311 311 text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
312 312 # Revisions links
313 313 text = text.gsub(/\[(\d+)\]/, 'r\1')
314 314 # Ticket number re-writing
315 315 text = text.gsub(/#(\d+)/) do |s|
316 316 if $1.length < 10
317 317 # TICKET_MAP[$1.to_i] ||= $1
318 318 "\##{TICKET_MAP[$1.to_i] || $1}"
319 319 else
320 320 s
321 321 end
322 322 end
323 323 # We would like to convert the Code highlighting too
324 324 # This will go into the next line.
325 325 shebang_line = false
326 326 # Reguar expression for start of code
327 327 pre_re = /\{\{\{/
328 328 # Code hightlighing...
329 329 shebang_re = /^\#\!([a-z]+)/
330 330 # Regular expression for end of code
331 331 pre_end_re = /\}\}\}/
332 332
333 333 # Go through the whole text..extract it line by line
334 334 text = text.gsub(/^(.*)$/) do |line|
335 335 m_pre = pre_re.match(line)
336 336 if m_pre
337 337 line = '<pre>'
338 338 else
339 339 m_sl = shebang_re.match(line)
340 340 if m_sl
341 341 shebang_line = true
342 342 line = '<code class="' + m_sl[1] + '">'
343 343 end
344 344 m_pre_end = pre_end_re.match(line)
345 345 if m_pre_end
346 346 line = '</pre>'
347 347 if shebang_line
348 348 line = '</code>' + line
349 349 end
350 350 end
351 351 end
352 352 line
353 353 end
354 354
355 355 # Highlighting
356 356 text = text.gsub(/'''''([^\s])/, '_*\1')
357 357 text = text.gsub(/([^\s])'''''/, '\1*_')
358 358 text = text.gsub(/'''/, '*')
359 359 text = text.gsub(/''/, '_')
360 360 text = text.gsub(/__/, '+')
361 361 text = text.gsub(/~~/, '-')
362 362 text = text.gsub(/`/, '@')
363 363 text = text.gsub(/,,/, '~')
364 364 # Lists
365 365 text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
366 366
367 367 text
368 368 end
369 369
370 370 def self.migrate
371 371 establish_connection
372 372
373 373 # Quick database test
374 374 TracComponent.count
375 375
376 376 migrated_components = 0
377 377 migrated_milestones = 0
378 378 migrated_tickets = 0
379 379 migrated_custom_values = 0
380 380 migrated_ticket_attachments = 0
381 381 migrated_wiki_edits = 0
382 382 migrated_wiki_attachments = 0
383 383
384 384 #Wiki system initializing...
385 385 @target_project.wiki.destroy if @target_project.wiki
386 386 @target_project.reload
387 387 wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
388 388 wiki_edit_count = 0
389 389
390 390 # Components
391 391 print "Migrating components"
392 392 issues_category_map = {}
393 TracComponent.find(:all).each do |component|
393 TracComponent.all.each do |component|
394 394 print '.'
395 395 STDOUT.flush
396 396 c = IssueCategory.new :project => @target_project,
397 397 :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
398 398 next unless c.save
399 399 issues_category_map[component.name] = c
400 400 migrated_components += 1
401 401 end
402 402 puts
403 403
404 404 # Milestones
405 405 print "Migrating milestones"
406 406 version_map = {}
407 TracMilestone.find(:all).each do |milestone|
407 TracMilestone.all.each do |milestone|
408 408 print '.'
409 409 STDOUT.flush
410 410 # First we try to find the wiki page...
411 411 p = wiki.find_or_new_page(milestone.name.to_s)
412 412 p.content = WikiContent.new(:page => p) if p.new_record?
413 413 p.content.text = milestone.description.to_s
414 414 p.content.author = find_or_create_user('trac')
415 415 p.content.comments = 'Milestone'
416 416 p.save
417 417
418 418 v = Version.new :project => @target_project,
419 419 :name => encode(milestone.name[0, limit_for(Version, 'name')]),
420 420 :description => nil,
421 421 :wiki_page_title => milestone.name.to_s,
422 422 :effective_date => milestone.completed
423 423
424 424 next unless v.save
425 425 version_map[milestone.name] = v
426 426 migrated_milestones += 1
427 427 end
428 428 puts
429 429
430 430 # Custom fields
431 431 # TODO: read trac.ini instead
432 432 print "Migrating custom fields"
433 433 custom_field_map = {}
434 434 TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
435 435 print '.'
436 436 STDOUT.flush
437 437 # Redmine custom field name
438 438 field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
439 439 # Find if the custom already exists in Redmine
440 440 f = IssueCustomField.find_by_name(field_name)
441 441 # Or create a new one
442 442 f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
443 443 :field_format => 'string')
444 444
445 445 next if f.new_record?
446 f.trackers = Tracker.find(:all)
446 f.trackers = Tracker.all
447 447 f.projects << @target_project
448 448 custom_field_map[field.name] = f
449 449 end
450 450 puts
451 451
452 452 # Trac 'resolution' field as a Redmine custom field
453 453 r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
454 454 r = IssueCustomField.new(:name => 'Resolution',
455 455 :field_format => 'list',
456 456 :is_filter => true) if r.nil?
457 r.trackers = Tracker.find(:all)
457 r.trackers = Tracker.all
458 458 r.projects << @target_project
459 459 r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
460 460 r.save!
461 461 custom_field_map['resolution'] = r
462 462
463 463 # Tickets
464 464 print "Migrating tickets"
465 465 TracTicket.find_each(:batch_size => 200) do |ticket|
466 466 print '.'
467 467 STDOUT.flush
468 468 i = Issue.new :project => @target_project,
469 469 :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
470 470 :description => convert_wiki_text(encode(ticket.description)),
471 471 :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
472 472 :created_on => ticket.time
473 473 i.author = find_or_create_user(ticket.reporter)
474 474 i.category = issues_category_map[ticket.component] unless ticket.component.blank?
475 475 i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
476 476 i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
477 477 i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
478 478 i.id = ticket.id unless Issue.exists?(ticket.id)
479 479 next unless Time.fake(ticket.changetime) { i.save }
480 480 TICKET_MAP[ticket.id] = i.id
481 481 migrated_tickets += 1
482 482
483 483 # Owner
484 484 unless ticket.owner.blank?
485 485 i.assigned_to = find_or_create_user(ticket.owner, true)
486 486 Time.fake(ticket.changetime) { i.save }
487 487 end
488 488
489 489 # Comments and status/resolution changes
490 490 ticket.ticket_changes.group_by(&:time).each do |time, changeset|
491 491 status_change = changeset.select {|change| change.field == 'status'}.first
492 492 resolution_change = changeset.select {|change| change.field == 'resolution'}.first
493 493 comment_change = changeset.select {|change| change.field == 'comment'}.first
494 494
495 495 n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
496 496 :created_on => time
497 497 n.user = find_or_create_user(changeset.first.author)
498 498 n.journalized = i
499 499 if status_change &&
500 500 STATUS_MAPPING[status_change.oldvalue] &&
501 501 STATUS_MAPPING[status_change.newvalue] &&
502 502 (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
503 503 n.details << JournalDetail.new(:property => 'attr',
504 504 :prop_key => 'status_id',
505 505 :old_value => STATUS_MAPPING[status_change.oldvalue].id,
506 506 :value => STATUS_MAPPING[status_change.newvalue].id)
507 507 end
508 508 if resolution_change
509 509 n.details << JournalDetail.new(:property => 'cf',
510 510 :prop_key => custom_field_map['resolution'].id,
511 511 :old_value => resolution_change.oldvalue,
512 512 :value => resolution_change.newvalue)
513 513 end
514 514 n.save unless n.details.empty? && n.notes.blank?
515 515 end
516 516
517 517 # Attachments
518 518 ticket.attachments.each do |attachment|
519 519 next unless attachment.exist?
520 520 attachment.open {
521 521 a = Attachment.new :created_on => attachment.time
522 522 a.file = attachment
523 523 a.author = find_or_create_user(attachment.author)
524 524 a.container = i
525 525 a.description = attachment.description
526 526 migrated_ticket_attachments += 1 if a.save
527 527 }
528 528 end
529 529
530 530 # Custom fields
531 531 custom_values = ticket.customs.inject({}) do |h, custom|
532 532 if custom_field = custom_field_map[custom.name]
533 533 h[custom_field.id] = custom.value
534 534 migrated_custom_values += 1
535 535 end
536 536 h
537 537 end
538 538 if custom_field_map['resolution'] && !ticket.resolution.blank?
539 539 custom_values[custom_field_map['resolution'].id] = ticket.resolution
540 540 end
541 541 i.custom_field_values = custom_values
542 542 i.save_custom_field_values
543 543 end
544 544
545 545 # update issue id sequence if needed (postgresql)
546 546 Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
547 547 puts
548 548
549 549 # Wiki
550 550 print "Migrating wiki"
551 551 if wiki.save
552 TracWikiPage.find(:all, :order => 'name, version').each do |page|
552 TracWikiPage.order('name, version').all.each do |page|
553 553 # Do not migrate Trac manual wiki pages
554 554 next if TRAC_WIKI_PAGES.include?(page.name)
555 555 wiki_edit_count += 1
556 556 print '.'
557 557 STDOUT.flush
558 558 p = wiki.find_or_new_page(page.name)
559 559 p.content = WikiContent.new(:page => p) if p.new_record?
560 560 p.content.text = page.text
561 561 p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
562 562 p.content.comments = page.comment
563 563 Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
564 564
565 565 next if p.content.new_record?
566 566 migrated_wiki_edits += 1
567 567
568 568 # Attachments
569 569 page.attachments.each do |attachment|
570 570 next unless attachment.exist?
571 571 next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
572 572 attachment.open {
573 573 a = Attachment.new :created_on => attachment.time
574 574 a.file = attachment
575 575 a.author = find_or_create_user(attachment.author)
576 576 a.description = attachment.description
577 577 a.container = p
578 578 migrated_wiki_attachments += 1 if a.save
579 579 }
580 580 end
581 581 end
582 582
583 583 wiki.reload
584 584 wiki.pages.each do |page|
585 585 page.content.text = convert_wiki_text(page.content.text)
586 586 Time.fake(page.content.updated_on) { page.content.save }
587 587 end
588 588 end
589 589 puts
590 590
591 591 puts
592 592 puts "Components: #{migrated_components}/#{TracComponent.count}"
593 593 puts "Milestones: #{migrated_milestones}/#{TracMilestone.count}"
594 594 puts "Tickets: #{migrated_tickets}/#{TracTicket.count}"
595 595 puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
596 596 puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
597 597 puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edit_count}"
598 598 puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
599 599 end
600 600
601 601 def self.limit_for(klass, attribute)
602 602 klass.columns_hash[attribute.to_s].limit
603 603 end
604 604
605 605 def self.encoding(charset)
606 606 @ic = Iconv.new('UTF-8', charset)
607 607 rescue Iconv::InvalidEncoding
608 608 puts "Invalid encoding!"
609 609 return false
610 610 end
611 611
612 612 def self.set_trac_directory(path)
613 613 @@trac_directory = path
614 614 raise "This directory doesn't exist!" unless File.directory?(path)
615 615 raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
616 616 @@trac_directory
617 617 rescue Exception => e
618 618 puts e
619 619 return false
620 620 end
621 621
622 622 def self.trac_directory
623 623 @@trac_directory
624 624 end
625 625
626 626 def self.set_trac_adapter(adapter)
627 627 return false if adapter.blank?
628 628 raise "Unknown adapter: #{adapter}!" unless %w(sqlite3 mysql postgresql).include?(adapter)
629 629 # If adapter is sqlite or sqlite3, make sure that trac.db exists
630 630 raise "#{trac_db_path} doesn't exist!" if %w(sqlite3).include?(adapter) && !File.exist?(trac_db_path)
631 631 @@trac_adapter = adapter
632 632 rescue Exception => e
633 633 puts e
634 634 return false
635 635 end
636 636
637 637 def self.set_trac_db_host(host)
638 638 return nil if host.blank?
639 639 @@trac_db_host = host
640 640 end
641 641
642 642 def self.set_trac_db_port(port)
643 643 return nil if port.to_i == 0
644 644 @@trac_db_port = port.to_i
645 645 end
646 646
647 647 def self.set_trac_db_name(name)
648 648 return nil if name.blank?
649 649 @@trac_db_name = name
650 650 end
651 651
652 652 def self.set_trac_db_username(username)
653 653 @@trac_db_username = username
654 654 end
655 655
656 656 def self.set_trac_db_password(password)
657 657 @@trac_db_password = password
658 658 end
659 659
660 660 def self.set_trac_db_schema(schema)
661 661 @@trac_db_schema = schema
662 662 end
663 663
664 664 mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
665 665
666 666 def self.trac_db_path; "#{trac_directory}/db/trac.db" end
667 667 def self.trac_attachments_directory; "#{trac_directory}/attachments" end
668 668
669 669 def self.target_project_identifier(identifier)
670 670 project = Project.find_by_identifier(identifier)
671 671 if !project
672 672 # create the target project
673 673 project = Project.new :name => identifier.humanize,
674 674 :description => ''
675 675 project.identifier = identifier
676 676 puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
677 677 # enable issues and wiki for the created project
678 678 project.enabled_module_names = ['issue_tracking', 'wiki']
679 679 else
680 680 puts
681 681 puts "This project already exists in your Redmine database."
682 682 print "Are you sure you want to append data to this project ? [Y/n] "
683 683 STDOUT.flush
684 684 exit if STDIN.gets.match(/^n$/i)
685 685 end
686 686 project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
687 687 project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
688 688 @target_project = project.new_record? ? nil : project
689 689 @target_project.reload
690 690 end
691 691
692 692 def self.connection_params
693 693 if trac_adapter == 'sqlite3'
694 694 {:adapter => 'sqlite3',
695 695 :database => trac_db_path}
696 696 else
697 697 {:adapter => trac_adapter,
698 698 :database => trac_db_name,
699 699 :host => trac_db_host,
700 700 :port => trac_db_port,
701 701 :username => trac_db_username,
702 702 :password => trac_db_password,
703 703 :schema_search_path => trac_db_schema
704 704 }
705 705 end
706 706 end
707 707
708 708 def self.establish_connection
709 709 constants.each do |const|
710 710 klass = const_get(const)
711 711 next unless klass.respond_to? 'establish_connection'
712 712 klass.establish_connection connection_params
713 713 end
714 714 end
715 715
716 716 private
717 717 def self.encode(text)
718 718 @ic.iconv text
719 719 rescue
720 720 text
721 721 end
722 722 end
723 723
724 724 puts
725 725 if Redmine::DefaultData::Loader.no_data?
726 726 puts "Redmine configuration need to be loaded before importing data."
727 727 puts "Please, run this first:"
728 728 puts
729 729 puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
730 730 exit
731 731 end
732 732
733 733 puts "WARNING: a new project will be added to Redmine during this process."
734 734 print "Are you sure you want to continue ? [y/N] "
735 735 STDOUT.flush
736 736 break unless STDIN.gets.match(/^y$/i)
737 737 puts
738 738
739 739 def prompt(text, options = {}, &block)
740 740 default = options[:default] || ''
741 741 while true
742 742 print "#{text} [#{default}]: "
743 743 STDOUT.flush
744 744 value = STDIN.gets.chomp!
745 745 value = default if value.blank?
746 746 break if yield value
747 747 end
748 748 end
749 749
750 750 DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
751 751
752 752 prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
753 753 prompt('Trac database adapter (sqlite3, mysql2, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter}
754 754 unless %w(sqlite3).include?(TracMigrate.trac_adapter)
755 755 prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
756 756 prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
757 757 prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
758 758 prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
759 759 prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
760 760 prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
761 761 end
762 762 prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
763 763 prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
764 764 puts
765 765
766 766 # Turn off email notifications
767 767 Setting.notified_events = []
768 768
769 769 TracMigrate.migrate
770 770 end
771 771 end
772 772
@@ -1,219 +1,219
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 RolesControllerTest < ActionController::TestCase
21 21 fixtures :roles, :users, :members, :member_roles, :workflows, :trackers
22 22
23 23 def setup
24 24 @controller = RolesController.new
25 25 @request = ActionController::TestRequest.new
26 26 @response = ActionController::TestResponse.new
27 27 User.current = nil
28 28 @request.session[:user_id] = 1 # admin
29 29 end
30 30
31 31 def test_index
32 32 get :index
33 33 assert_response :success
34 34 assert_template 'index'
35 35
36 36 assert_not_nil assigns(:roles)
37 assert_equal Role.find(:all, :order => 'builtin, position'), assigns(:roles)
37 assert_equal Role.order('builtin, position').all, assigns(:roles)
38 38
39 39 assert_tag :tag => 'a', :attributes => { :href => '/roles/1/edit' },
40 40 :content => 'Manager'
41 41 end
42 42
43 43 def test_new
44 44 get :new
45 45 assert_response :success
46 46 assert_template 'new'
47 47 end
48 48
49 49 def test_new_with_copy
50 50 copy_from = Role.find(2)
51 51
52 52 get :new, :copy => copy_from.id.to_s
53 53 assert_response :success
54 54 assert_template 'new'
55 55
56 56 role = assigns(:role)
57 57 assert_equal copy_from.permissions, role.permissions
58 58
59 59 assert_select 'form' do
60 60 # blank name
61 61 assert_select 'input[name=?][value=]', 'role[name]'
62 62 # edit_project permission checked
63 63 assert_select 'input[type=checkbox][name=?][value=edit_project][checked=checked]', 'role[permissions][]'
64 64 # add_project permission not checked
65 65 assert_select 'input[type=checkbox][name=?][value=add_project]', 'role[permissions][]'
66 66 assert_select 'input[type=checkbox][name=?][value=add_project][checked=checked]', 'role[permissions][]', 0
67 67 # workflow copy selected
68 68 assert_select 'select[name=?]', 'copy_workflow_from' do
69 69 assert_select 'option[value=2][selected=selected]'
70 70 end
71 71 end
72 72 end
73 73
74 74 def test_create_with_validaton_failure
75 75 post :create, :role => {:name => '',
76 76 :permissions => ['add_issues', 'edit_issues', 'log_time', ''],
77 77 :assignable => '0'}
78 78
79 79 assert_response :success
80 80 assert_template 'new'
81 81 assert_tag :tag => 'div', :attributes => { :id => 'errorExplanation' }
82 82 end
83 83
84 84 def test_create_without_workflow_copy
85 85 post :create, :role => {:name => 'RoleWithoutWorkflowCopy',
86 86 :permissions => ['add_issues', 'edit_issues', 'log_time', ''],
87 87 :assignable => '0'}
88 88
89 89 assert_redirected_to '/roles'
90 90 role = Role.find_by_name('RoleWithoutWorkflowCopy')
91 91 assert_not_nil role
92 92 assert_equal [:add_issues, :edit_issues, :log_time], role.permissions
93 93 assert !role.assignable?
94 94 end
95 95
96 96 def test_create_with_workflow_copy
97 97 post :create, :role => {:name => 'RoleWithWorkflowCopy',
98 98 :permissions => ['add_issues', 'edit_issues', 'log_time', ''],
99 99 :assignable => '0'},
100 100 :copy_workflow_from => '1'
101 101
102 102 assert_redirected_to '/roles'
103 103 role = Role.find_by_name('RoleWithWorkflowCopy')
104 104 assert_not_nil role
105 105 assert_equal Role.find(1).workflow_rules.size, role.workflow_rules.size
106 106 end
107 107
108 108 def test_edit
109 109 get :edit, :id => 1
110 110 assert_response :success
111 111 assert_template 'edit'
112 112 assert_equal Role.find(1), assigns(:role)
113 113 assert_select 'select[name=?]', 'role[issues_visibility]'
114 114 end
115 115
116 116 def test_edit_anonymous
117 117 get :edit, :id => Role.anonymous.id
118 118 assert_response :success
119 119 assert_template 'edit'
120 120 assert_select 'select[name=?]', 'role[issues_visibility]', 0
121 121 end
122 122
123 123 def test_edit_invalid_should_respond_with_404
124 124 get :edit, :id => 999
125 125 assert_response 404
126 126 end
127 127
128 128 def test_update
129 129 put :update, :id => 1,
130 130 :role => {:name => 'Manager',
131 131 :permissions => ['edit_project', ''],
132 132 :assignable => '0'}
133 133
134 134 assert_redirected_to '/roles'
135 135 role = Role.find(1)
136 136 assert_equal [:edit_project], role.permissions
137 137 end
138 138
139 139 def test_update_with_failure
140 140 put :update, :id => 1, :role => {:name => ''}
141 141 assert_response :success
142 142 assert_template 'edit'
143 143 end
144 144
145 145 def test_destroy
146 146 r = Role.create!(:name => 'ToBeDestroyed', :permissions => [:view_wiki_pages])
147 147
148 148 delete :destroy, :id => r
149 149 assert_redirected_to '/roles'
150 150 assert_nil Role.find_by_id(r.id)
151 151 end
152 152
153 153 def test_destroy_role_in_use
154 154 delete :destroy, :id => 1
155 155 assert_redirected_to '/roles'
156 156 assert_equal 'This role is in use and cannot be deleted.', flash[:error]
157 157 assert_not_nil Role.find_by_id(1)
158 158 end
159 159
160 160 def test_get_permissions
161 161 get :permissions
162 162 assert_response :success
163 163 assert_template 'permissions'
164 164
165 165 assert_not_nil assigns(:roles)
166 assert_equal Role.find(:all, :order => 'builtin, position'), assigns(:roles)
166 assert_equal Role.order('builtin, position').all, assigns(:roles)
167 167
168 168 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
169 169 :name => 'permissions[3][]',
170 170 :value => 'add_issues',
171 171 :checked => 'checked' }
172 172
173 173 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
174 174 :name => 'permissions[3][]',
175 175 :value => 'delete_issues',
176 176 :checked => nil }
177 177 end
178 178
179 179 def test_post_permissions
180 180 post :permissions, :permissions => { '0' => '', '1' => ['edit_issues'], '3' => ['add_issues', 'delete_issues']}
181 181 assert_redirected_to '/roles'
182 182
183 183 assert_equal [:edit_issues], Role.find(1).permissions
184 184 assert_equal [:add_issues, :delete_issues], Role.find(3).permissions
185 185 assert Role.find(2).permissions.empty?
186 186 end
187 187
188 188 def test_clear_all_permissions
189 189 post :permissions, :permissions => { '0' => '' }
190 190 assert_redirected_to '/roles'
191 191 assert Role.find(1).permissions.empty?
192 192 end
193 193
194 194 def test_move_highest
195 195 put :update, :id => 3, :role => {:move_to => 'highest'}
196 196 assert_redirected_to '/roles'
197 197 assert_equal 1, Role.find(3).position
198 198 end
199 199
200 200 def test_move_higher
201 201 position = Role.find(3).position
202 202 put :update, :id => 3, :role => {:move_to => 'higher'}
203 203 assert_redirected_to '/roles'
204 204 assert_equal position - 1, Role.find(3).position
205 205 end
206 206
207 207 def test_move_lower
208 208 position = Role.find(2).position
209 209 put :update, :id => 2, :role => {:move_to => 'lower'}
210 210 assert_redirected_to '/roles'
211 211 assert_equal position + 1, Role.find(2).position
212 212 end
213 213
214 214 def test_move_lowest
215 215 put :update, :id => 2, :role => {:move_to => 'lowest'}
216 216 assert_redirected_to '/roles'
217 217 assert_equal Role.count, Role.find(2).position
218 218 end
219 219 end
@@ -1,312 +1,315
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 require 'workflows_controller'
20 20
21 21 # Re-raise errors caught by the controller.
22 22 class WorkflowsController; def rescue_action(e) raise e end; end
23 23
24 24 class WorkflowsControllerTest < ActionController::TestCase
25 25 fixtures :roles, :trackers, :workflows, :users, :issue_statuses
26 26
27 27 def setup
28 28 @controller = WorkflowsController.new
29 29 @request = ActionController::TestRequest.new
30 30 @response = ActionController::TestResponse.new
31 31 User.current = nil
32 32 @request.session[:user_id] = 1 # admin
33 33 end
34 34
35 35 def test_index
36 36 get :index
37 37 assert_response :success
38 38 assert_template 'index'
39 39
40 40 count = WorkflowTransition.count(:all, :conditions => 'role_id = 1 AND tracker_id = 2')
41 41 assert_tag :tag => 'a', :content => count.to_s,
42 42 :attributes => { :href => '/workflows/edit?role_id=1&amp;tracker_id=2' }
43 43 end
44 44
45 45 def test_get_edit
46 46 get :edit
47 47 assert_response :success
48 48 assert_template 'edit'
49 49 assert_not_nil assigns(:roles)
50 50 assert_not_nil assigns(:trackers)
51 51 end
52 52
53 53 def test_get_edit_with_role_and_tracker
54 54 WorkflowTransition.delete_all
55 55 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 2, :new_status_id => 3)
56 56 WorkflowTransition.create!(:role_id => 2, :tracker_id => 1, :old_status_id => 3, :new_status_id => 5)
57 57
58 58 get :edit, :role_id => 2, :tracker_id => 1
59 59 assert_response :success
60 60 assert_template 'edit'
61 61
62 62 # used status only
63 63 assert_not_nil assigns(:statuses)
64 64 assert_equal [2, 3, 5], assigns(:statuses).collect(&:id)
65 65
66 66 # allowed transitions
67 67 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
68 68 :name => 'issue_status[3][5][]',
69 69 :value => 'always',
70 70 :checked => 'checked' }
71 71 # not allowed
72 72 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
73 73 :name => 'issue_status[3][2][]',
74 74 :value => 'always',
75 75 :checked => nil }
76 76 # unused
77 77 assert_no_tag :tag => 'input', :attributes => { :type => 'checkbox',
78 78 :name => 'issue_status[1][1][]' }
79 79 end
80 80
81 81 def test_get_edit_with_role_and_tracker_and_all_statuses
82 82 WorkflowTransition.delete_all
83 83
84 84 get :edit, :role_id => 2, :tracker_id => 1, :used_statuses_only => '0'
85 85 assert_response :success
86 86 assert_template 'edit'
87 87
88 88 assert_not_nil assigns(:statuses)
89 89 assert_equal IssueStatus.count, assigns(:statuses).size
90 90
91 91 assert_tag :tag => 'input', :attributes => { :type => 'checkbox',
92 92 :name => 'issue_status[1][1][]',
93 93 :value => 'always',
94 94 :checked => nil }
95 95 end
96 96
97 97 def test_post_edit
98 98 post :edit, :role_id => 2, :tracker_id => 1,
99 99 :issue_status => {
100 100 '4' => {'5' => ['always']},
101 101 '3' => {'1' => ['always'], '2' => ['always']}
102 102 }
103 103 assert_redirected_to '/workflows/edit?role_id=2&tracker_id=1'
104 104
105 105 assert_equal 3, WorkflowTransition.count(:conditions => {:tracker_id => 1, :role_id => 2})
106 106 assert_not_nil WorkflowTransition.find(:first, :conditions => {:role_id => 2, :tracker_id => 1, :old_status_id => 3, :new_status_id => 2})
107 107 assert_nil WorkflowTransition.find(:first, :conditions => {:role_id => 2, :tracker_id => 1, :old_status_id => 5, :new_status_id => 4})
108 108 end
109 109
110 110 def test_post_edit_with_additional_transitions
111 111 post :edit, :role_id => 2, :tracker_id => 1,
112 112 :issue_status => {
113 113 '4' => {'5' => ['always']},
114 114 '3' => {'1' => ['author'], '2' => ['assignee'], '4' => ['author', 'assignee']}
115 115 }
116 116 assert_redirected_to '/workflows/edit?role_id=2&tracker_id=1'
117 117
118 118 assert_equal 4, WorkflowTransition.count(:conditions => {:tracker_id => 1, :role_id => 2})
119 119
120 120 w = WorkflowTransition.find(:first, :conditions => {:role_id => 2, :tracker_id => 1, :old_status_id => 4, :new_status_id => 5})
121 121 assert ! w.author
122 122 assert ! w.assignee
123 123 w = WorkflowTransition.find(:first, :conditions => {:role_id => 2, :tracker_id => 1, :old_status_id => 3, :new_status_id => 1})
124 124 assert w.author
125 125 assert ! w.assignee
126 126 w = WorkflowTransition.find(:first, :conditions => {:role_id => 2, :tracker_id => 1, :old_status_id => 3, :new_status_id => 2})
127 127 assert ! w.author
128 128 assert w.assignee
129 129 w = WorkflowTransition.find(:first, :conditions => {:role_id => 2, :tracker_id => 1, :old_status_id => 3, :new_status_id => 4})
130 130 assert w.author
131 131 assert w.assignee
132 132 end
133 133
134 134 def test_clear_workflow
135 135 assert WorkflowTransition.count(:conditions => {:tracker_id => 1, :role_id => 2}) > 0
136 136
137 137 post :edit, :role_id => 2, :tracker_id => 1
138 138 assert_equal 0, WorkflowTransition.count(:conditions => {:tracker_id => 1, :role_id => 2})
139 139 end
140 140
141 141 def test_get_permissions
142 142 get :permissions
143 143
144 144 assert_response :success
145 145 assert_template 'permissions'
146 146 assert_not_nil assigns(:roles)
147 147 assert_not_nil assigns(:trackers)
148 148 end
149 149
150 150 def test_get_permissions_with_role_and_tracker
151 151 WorkflowPermission.delete_all
152 152 WorkflowPermission.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :field_name => 'assigned_to_id', :rule => 'required')
153 153 WorkflowPermission.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :field_name => 'fixed_version_id', :rule => 'required')
154 154 WorkflowPermission.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 3, :field_name => 'fixed_version_id', :rule => 'readonly')
155 155
156 156 get :permissions, :role_id => 1, :tracker_id => 2
157 157 assert_response :success
158 158 assert_template 'permissions'
159 159
160 160 assert_select 'input[name=role_id][value=1]'
161 161 assert_select 'input[name=tracker_id][value=2]'
162 162
163 163 # Required field
164 164 assert_select 'select[name=?]', 'permissions[assigned_to_id][2]' do
165 165 assert_select 'option[value=]'
166 166 assert_select 'option[value=][selected=selected]', 0
167 167 assert_select 'option[value=readonly]', :text => 'Read-only'
168 168 assert_select 'option[value=readonly][selected=selected]', 0
169 169 assert_select 'option[value=required]', :text => 'Required'
170 170 assert_select 'option[value=required][selected=selected]'
171 171 end
172 172
173 173 # Read-only field
174 174 assert_select 'select[name=?]', 'permissions[fixed_version_id][3]' do
175 175 assert_select 'option[value=]'
176 176 assert_select 'option[value=][selected=selected]', 0
177 177 assert_select 'option[value=readonly]', :text => 'Read-only'
178 178 assert_select 'option[value=readonly][selected=selected]'
179 179 assert_select 'option[value=required]', :text => 'Required'
180 180 assert_select 'option[value=required][selected=selected]', 0
181 181 end
182 182
183 183 # Other field
184 184 assert_select 'select[name=?]', 'permissions[due_date][3]' do
185 185 assert_select 'option[value=]'
186 186 assert_select 'option[value=][selected=selected]', 0
187 187 assert_select 'option[value=readonly]', :text => 'Read-only'
188 188 assert_select 'option[value=readonly][selected=selected]', 0
189 189 assert_select 'option[value=required]', :text => 'Required'
190 190 assert_select 'option[value=required][selected=selected]', 0
191 191 end
192 192 end
193 193
194 194 def test_get_permissions_with_required_custom_field_should_not_show_required_option
195 195 cf = IssueCustomField.create!(:name => 'Foo', :field_format => 'string', :tracker_ids => [1], :is_required => true)
196 196
197 197 get :permissions, :role_id => 1, :tracker_id => 1
198 198 assert_response :success
199 199 assert_template 'permissions'
200 200
201 201 # Custom field that is always required
202 202 # The default option is "(Required)"
203 203 assert_select 'select[name=?]', "permissions[#{cf.id}][3]" do
204 204 assert_select 'option[value=]'
205 205 assert_select 'option[value=readonly]', :text => 'Read-only'
206 206 assert_select 'option[value=required]', 0
207 207 end
208 208 end
209 209
210 210 def test_get_permissions_with_role_and_tracker_and_all_statuses
211 211 WorkflowTransition.delete_all
212 212
213 213 get :permissions, :role_id => 1, :tracker_id => 2, :used_statuses_only => '0'
214 214 assert_response :success
215 215 assert_equal IssueStatus.sorted.all, assigns(:statuses)
216 216 end
217 217
218 218 def test_post_permissions
219 219 WorkflowPermission.delete_all
220 220
221 221 post :permissions, :role_id => 1, :tracker_id => 2, :permissions => {
222 222 'assigned_to_id' => {'1' => '', '2' => 'readonly', '3' => ''},
223 223 'fixed_version_id' => {'1' => 'required', '2' => 'readonly', '3' => ''},
224 224 'due_date' => {'1' => '', '2' => '', '3' => ''},
225 225 }
226 226 assert_redirected_to '/workflows/permissions?role_id=1&tracker_id=2'
227 227
228 228 workflows = WorkflowPermission.all
229 229 assert_equal 3, workflows.size
230 230 workflows.each do |workflow|
231 231 assert_equal 1, workflow.role_id
232 232 assert_equal 2, workflow.tracker_id
233 233 end
234 234 assert workflows.detect {|wf| wf.old_status_id == 2 && wf.field_name == 'assigned_to_id' && wf.rule == 'readonly'}
235 235 assert workflows.detect {|wf| wf.old_status_id == 1 && wf.field_name == 'fixed_version_id' && wf.rule == 'required'}
236 236 assert workflows.detect {|wf| wf.old_status_id == 2 && wf.field_name == 'fixed_version_id' && wf.rule == 'readonly'}
237 237 end
238 238
239 239 def test_post_permissions_should_clear_permissions
240 240 WorkflowPermission.delete_all
241 241 WorkflowPermission.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :field_name => 'assigned_to_id', :rule => 'required')
242 242 WorkflowPermission.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :field_name => 'fixed_version_id', :rule => 'required')
243 243 wf1 = WorkflowPermission.create!(:role_id => 1, :tracker_id => 3, :old_status_id => 2, :field_name => 'fixed_version_id', :rule => 'required')
244 244 wf2 = WorkflowPermission.create!(:role_id => 2, :tracker_id => 2, :old_status_id => 3, :field_name => 'fixed_version_id', :rule => 'readonly')
245 245
246 246 post :permissions, :role_id => 1, :tracker_id => 2
247 247 assert_redirected_to '/workflows/permissions?role_id=1&tracker_id=2'
248 248
249 249 workflows = WorkflowPermission.all
250 250 assert_equal 2, workflows.size
251 251 assert wf1.reload
252 252 assert wf2.reload
253 253 end
254 254
255 255 def test_get_copy
256 256 get :copy
257 257 assert_response :success
258 258 assert_template 'copy'
259 259 assert_select 'select[name=source_tracker_id]' do
260 260 assert_select 'option[value=1]', :text => 'Bug'
261 261 end
262 262 assert_select 'select[name=source_role_id]' do
263 263 assert_select 'option[value=2]', :text => 'Developer'
264 264 end
265 265 assert_select 'select[name=?]', 'target_tracker_ids[]' do
266 266 assert_select 'option[value=3]', :text => 'Support request'
267 267 end
268 268 assert_select 'select[name=?]', 'target_role_ids[]' do
269 269 assert_select 'option[value=1]', :text => 'Manager'
270 270 end
271 271 end
272 272
273 273 def test_post_copy_one_to_one
274 274 source_transitions = status_transitions(:tracker_id => 1, :role_id => 2)
275 275
276 276 post :copy, :source_tracker_id => '1', :source_role_id => '2',
277 277 :target_tracker_ids => ['3'], :target_role_ids => ['1']
278 278 assert_response 302
279 279 assert_equal source_transitions, status_transitions(:tracker_id => 3, :role_id => 1)
280 280 end
281 281
282 282 def test_post_copy_one_to_many
283 283 source_transitions = status_transitions(:tracker_id => 1, :role_id => 2)
284 284
285 285 post :copy, :source_tracker_id => '1', :source_role_id => '2',
286 286 :target_tracker_ids => ['2', '3'], :target_role_ids => ['1', '3']
287 287 assert_response 302
288 288 assert_equal source_transitions, status_transitions(:tracker_id => 2, :role_id => 1)
289 289 assert_equal source_transitions, status_transitions(:tracker_id => 3, :role_id => 1)
290 290 assert_equal source_transitions, status_transitions(:tracker_id => 2, :role_id => 3)
291 291 assert_equal source_transitions, status_transitions(:tracker_id => 3, :role_id => 3)
292 292 end
293 293
294 294 def test_post_copy_many_to_many
295 295 source_t2 = status_transitions(:tracker_id => 2, :role_id => 2)
296 296 source_t3 = status_transitions(:tracker_id => 3, :role_id => 2)
297 297
298 298 post :copy, :source_tracker_id => 'any', :source_role_id => '2',
299 299 :target_tracker_ids => ['2', '3'], :target_role_ids => ['1', '3']
300 300 assert_response 302
301 301 assert_equal source_t2, status_transitions(:tracker_id => 2, :role_id => 1)
302 302 assert_equal source_t3, status_transitions(:tracker_id => 3, :role_id => 1)
303 303 assert_equal source_t2, status_transitions(:tracker_id => 2, :role_id => 3)
304 304 assert_equal source_t3, status_transitions(:tracker_id => 3, :role_id => 3)
305 305 end
306 306
307 307 # Returns an array of status transitions that can be compared
308 308 def status_transitions(conditions)
309 WorkflowTransition.find(:all, :conditions => conditions,
310 :order => 'tracker_id, role_id, old_status_id, new_status_id').collect {|w| [w.old_status, w.new_status_id]}
309 WorkflowTransition.
310 where(conditions).
311 order('tracker_id, role_id, old_status_id, new_status_id').
312 all.
313 collect {|w| [w.old_status, w.new_status_id]}
311 314 end
312 315 end
@@ -1,1164 +1,1164
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../../test_helper', __FILE__)
21 21
22 22 class ApplicationHelperTest < ActionView::TestCase
23 23 include ERB::Util
24 24 include Rails.application.routes.url_helpers
25 25
26 26 fixtures :projects, :roles, :enabled_modules, :users,
27 27 :repositories, :changesets,
28 28 :trackers, :issue_statuses, :issues, :versions, :documents,
29 29 :wikis, :wiki_pages, :wiki_contents,
30 30 :boards, :messages, :news,
31 31 :attachments, :enumerations
32 32
33 33 def setup
34 34 super
35 35 set_tmp_attachments_directory
36 36 end
37 37
38 38 context "#link_to_if_authorized" do
39 39 context "authorized user" do
40 40 should "be tested"
41 41 end
42 42
43 43 context "unauthorized user" do
44 44 should "be tested"
45 45 end
46 46
47 47 should "allow using the :controller and :action for the target link" do
48 48 User.current = User.find_by_login('admin')
49 49
50 50 @project = Issue.first.project # Used by helper
51 51 response = link_to_if_authorized("By controller/action",
52 52 {:controller => 'issues', :action => 'edit', :id => Issue.first.id})
53 53 assert_match /href/, response
54 54 end
55 55
56 56 end
57 57
58 58 def test_auto_links
59 59 to_test = {
60 60 'http://foo.bar' => '<a class="external" href="http://foo.bar">http://foo.bar</a>',
61 61 'http://foo.bar/~user' => '<a class="external" href="http://foo.bar/~user">http://foo.bar/~user</a>',
62 62 'http://foo.bar.' => '<a class="external" href="http://foo.bar">http://foo.bar</a>.',
63 63 'https://foo.bar.' => '<a class="external" href="https://foo.bar">https://foo.bar</a>.',
64 64 'This is a link: http://foo.bar.' => 'This is a link: <a class="external" href="http://foo.bar">http://foo.bar</a>.',
65 65 'A link (eg. http://foo.bar).' => 'A link (eg. <a class="external" href="http://foo.bar">http://foo.bar</a>).',
66 66 'http://foo.bar/foo.bar#foo.bar.' => '<a class="external" href="http://foo.bar/foo.bar#foo.bar">http://foo.bar/foo.bar#foo.bar</a>.',
67 67 'http://www.foo.bar/Test_(foobar)' => '<a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>',
68 68 '(see inline link : http://www.foo.bar/Test_(foobar))' => '(see inline link : <a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>)',
69 69 '(see inline link : http://www.foo.bar/Test)' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>)',
70 70 '(see inline link : http://www.foo.bar/Test).' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>).',
71 71 '(see "inline link":http://www.foo.bar/Test_(foobar))' => '(see <a href="http://www.foo.bar/Test_(foobar)" class="external">inline link</a>)',
72 72 '(see "inline link":http://www.foo.bar/Test)' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>)',
73 73 '(see "inline link":http://www.foo.bar/Test).' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>).',
74 74 'www.foo.bar' => '<a class="external" href="http://www.foo.bar">www.foo.bar</a>',
75 75 'http://foo.bar/page?p=1&t=z&s=' => '<a class="external" href="http://foo.bar/page?p=1&#38;t=z&#38;s=">http://foo.bar/page?p=1&#38;t=z&#38;s=</a>',
76 76 'http://foo.bar/page#125' => '<a class="external" href="http://foo.bar/page#125">http://foo.bar/page#125</a>',
77 77 'http://foo@www.bar.com' => '<a class="external" href="http://foo@www.bar.com">http://foo@www.bar.com</a>',
78 78 'http://foo:bar@www.bar.com' => '<a class="external" href="http://foo:bar@www.bar.com">http://foo:bar@www.bar.com</a>',
79 79 'ftp://foo.bar' => '<a class="external" href="ftp://foo.bar">ftp://foo.bar</a>',
80 80 'ftps://foo.bar' => '<a class="external" href="ftps://foo.bar">ftps://foo.bar</a>',
81 81 'sftp://foo.bar' => '<a class="external" href="sftp://foo.bar">sftp://foo.bar</a>',
82 82 # two exclamation marks
83 83 'http://example.net/path!602815048C7B5C20!302.html' => '<a class="external" href="http://example.net/path!602815048C7B5C20!302.html">http://example.net/path!602815048C7B5C20!302.html</a>',
84 84 # escaping
85 85 'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo&quot;bar</a>',
86 86 # wrap in angle brackets
87 87 '<http://foo.bar>' => '&lt;<a class="external" href="http://foo.bar">http://foo.bar</a>&gt;'
88 88 }
89 89 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
90 90 end
91 91
92 92 if 'ruby'.respond_to?(:encoding)
93 93 def test_auto_links_with_non_ascii_characters
94 94 to_test = {
95 95 'http://foo.bar/тСст' => '<a class="external" href="http://foo.bar/тСст">http://foo.bar/тСст</a>'
96 96 }
97 97 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
98 98 end
99 99 else
100 100 puts 'Skipping test_auto_links_with_non_ascii_characters, unsupported ruby version'
101 101 end
102 102
103 103 def test_auto_mailto
104 104 assert_equal '<p><a class="email" href="mailto:test@foo.bar">test@foo.bar</a></p>',
105 105 textilizable('test@foo.bar')
106 106 end
107 107
108 108 def test_inline_images
109 109 to_test = {
110 110 '!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="" />',
111 111 'floating !>http://foo.bar/image.jpg!' => 'floating <div style="float:right"><img src="http://foo.bar/image.jpg" alt="" /></div>',
112 112 'with class !(some-class)http://foo.bar/image.jpg!' => 'with class <img src="http://foo.bar/image.jpg" class="some-class" alt="" />',
113 113 'with style !{width:100px;height:100px}http://foo.bar/image.jpg!' => 'with style <img src="http://foo.bar/image.jpg" style="width:100px;height:100px;" alt="" />',
114 114 'with title !http://foo.bar/image.jpg(This is a title)!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title" />',
115 115 'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a double-quoted &quot;title&quot;" alt="This is a double-quoted &quot;title&quot;" />',
116 116 }
117 117 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
118 118 end
119 119
120 120 def test_inline_images_inside_tags
121 121 raw = <<-RAW
122 122 h1. !foo.png! Heading
123 123
124 124 Centered image:
125 125
126 126 p=. !bar.gif!
127 127 RAW
128 128
129 129 assert textilizable(raw).include?('<img src="foo.png" alt="" />')
130 130 assert textilizable(raw).include?('<img src="bar.gif" alt="" />')
131 131 end
132 132
133 133 def test_attached_images
134 134 to_test = {
135 135 'Inline image: !logo.gif!' => 'Inline image: <img src="/attachments/download/3" title="This is a logo" alt="This is a logo" />',
136 136 'Inline image: !logo.GIF!' => 'Inline image: <img src="/attachments/download/3" title="This is a logo" alt="This is a logo" />',
137 137 'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="" />',
138 138 'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="" />',
139 139 # link image
140 140 '!logo.gif!:http://foo.bar/' => '<a href="http://foo.bar/"><img src="/attachments/download/3" title="This is a logo" alt="This is a logo" /></a>',
141 141 }
142 attachments = Attachment.find(:all)
142 attachments = Attachment.all
143 143 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
144 144 end
145 145
146 146 def test_attached_images_filename_extension
147 147 set_tmp_attachments_directory
148 148 a1 = Attachment.new(
149 149 :container => Issue.find(1),
150 150 :file => mock_file_with_options({:original_filename => "testtest.JPG"}),
151 151 :author => User.find(1))
152 152 assert a1.save
153 153 assert_equal "testtest.JPG", a1.filename
154 154 assert_equal "image/jpeg", a1.content_type
155 155 assert a1.image?
156 156
157 157 a2 = Attachment.new(
158 158 :container => Issue.find(1),
159 159 :file => mock_file_with_options({:original_filename => "testtest.jpeg"}),
160 160 :author => User.find(1))
161 161 assert a2.save
162 162 assert_equal "testtest.jpeg", a2.filename
163 163 assert_equal "image/jpeg", a2.content_type
164 164 assert a2.image?
165 165
166 166 a3 = Attachment.new(
167 167 :container => Issue.find(1),
168 168 :file => mock_file_with_options({:original_filename => "testtest.JPE"}),
169 169 :author => User.find(1))
170 170 assert a3.save
171 171 assert_equal "testtest.JPE", a3.filename
172 172 assert_equal "image/jpeg", a3.content_type
173 173 assert a3.image?
174 174
175 175 a4 = Attachment.new(
176 176 :container => Issue.find(1),
177 177 :file => mock_file_with_options({:original_filename => "Testtest.BMP"}),
178 178 :author => User.find(1))
179 179 assert a4.save
180 180 assert_equal "Testtest.BMP", a4.filename
181 181 assert_equal "image/x-ms-bmp", a4.content_type
182 182 assert a4.image?
183 183
184 184 to_test = {
185 185 'Inline image: !testtest.jpg!' =>
186 186 'Inline image: <img src="/attachments/download/' + a1.id.to_s + '" alt="" />',
187 187 'Inline image: !testtest.jpeg!' =>
188 188 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '" alt="" />',
189 189 'Inline image: !testtest.jpe!' =>
190 190 'Inline image: <img src="/attachments/download/' + a3.id.to_s + '" alt="" />',
191 191 'Inline image: !testtest.bmp!' =>
192 192 'Inline image: <img src="/attachments/download/' + a4.id.to_s + '" alt="" />',
193 193 }
194 194
195 195 attachments = [a1, a2, a3, a4]
196 196 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
197 197 end
198 198
199 199 def test_attached_images_should_read_later
200 200 set_fixtures_attachments_directory
201 201 a1 = Attachment.find(16)
202 202 assert_equal "testfile.png", a1.filename
203 203 assert a1.readable?
204 204 assert (! a1.visible?(User.anonymous))
205 205 assert a1.visible?(User.find(2))
206 206 a2 = Attachment.find(17)
207 207 assert_equal "testfile.PNG", a2.filename
208 208 assert a2.readable?
209 209 assert (! a2.visible?(User.anonymous))
210 210 assert a2.visible?(User.find(2))
211 211 assert a1.created_on < a2.created_on
212 212
213 213 to_test = {
214 214 'Inline image: !testfile.png!' =>
215 215 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '" alt="" />',
216 216 'Inline image: !Testfile.PNG!' =>
217 217 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '" alt="" />',
218 218 }
219 219 attachments = [a1, a2]
220 220 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
221 221 set_tmp_attachments_directory
222 222 end
223 223
224 224 def test_textile_external_links
225 225 to_test = {
226 226 'This is a "link":http://foo.bar' => 'This is a <a href="http://foo.bar" class="external">link</a>',
227 227 'This is an intern "link":/foo/bar' => 'This is an intern <a href="/foo/bar">link</a>',
228 228 '"link (Link title)":http://foo.bar' => '<a href="http://foo.bar" title="Link title" class="external">link</a>',
229 229 '"link (Link title with "double-quotes")":http://foo.bar' => '<a href="http://foo.bar" title="Link title with &quot;double-quotes&quot;" class="external">link</a>',
230 230 "This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":</p>\n\n\n\t<p>Another paragraph",
231 231 # no multiline link text
232 232 "This is a double quote \"on the first line\nand another on a second line\":test" => "This is a double quote \"on the first line<br />and another on a second line\":test",
233 233 # mailto link
234 234 "\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" => "<a href=\"mailto:sysadmin@example.com?subject=redmine%20permissions\">system administrator</a>",
235 235 # two exclamation marks
236 236 '"a link":http://example.net/path!602815048C7B5C20!302.html' => '<a href="http://example.net/path!602815048C7B5C20!302.html" class="external">a link</a>',
237 237 # escaping
238 238 '"test":http://foo"bar' => '<a href="http://foo&quot;bar" class="external">test</a>',
239 239 }
240 240 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
241 241 end
242 242
243 243 if 'ruby'.respond_to?(:encoding)
244 244 def test_textile_external_links_with_non_ascii_characters
245 245 to_test = {
246 246 'This is a "link":http://foo.bar/тСст' => 'This is a <a href="http://foo.bar/тСст" class="external">link</a>'
247 247 }
248 248 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
249 249 end
250 250 else
251 251 puts 'Skipping test_textile_external_links_with_non_ascii_characters, unsupported ruby version'
252 252 end
253 253
254 254 def test_redmine_links
255 255 issue_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3},
256 256 :class => 'issue status-1 priority-4 priority-lowest overdue', :title => 'Error 281 when updating a recipe (New)')
257 257 note_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3, :anchor => 'note-14'},
258 258 :class => 'issue status-1 priority-4 priority-lowest overdue', :title => 'Error 281 when updating a recipe (New)')
259 259
260 260 changeset_link = link_to('r1', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 1},
261 261 :class => 'changeset', :title => 'My very first commit')
262 262 changeset_link2 = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
263 263 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
264 264
265 265 document_link = link_to('Test document', {:controller => 'documents', :action => 'show', :id => 1},
266 266 :class => 'document')
267 267
268 268 version_link = link_to('1.0', {:controller => 'versions', :action => 'show', :id => 2},
269 269 :class => 'version')
270 270
271 271 board_url = {:controller => 'boards', :action => 'show', :id => 2, :project_id => 'ecookbook'}
272 272
273 273 message_url = {:controller => 'messages', :action => 'show', :board_id => 1, :id => 4}
274 274
275 275 news_url = {:controller => 'news', :action => 'show', :id => 1}
276 276
277 277 project_url = {:controller => 'projects', :action => 'show', :id => 'subproject1'}
278 278
279 279 source_url = '/projects/ecookbook/repository/entry/some/file'
280 280 source_url_with_rev = '/projects/ecookbook/repository/revisions/52/entry/some/file'
281 281 source_url_with_ext = '/projects/ecookbook/repository/entry/some/file.ext'
282 282 source_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/entry/some/file.ext'
283 283
284 284 export_url = '/projects/ecookbook/repository/raw/some/file'
285 285 export_url_with_rev = '/projects/ecookbook/repository/revisions/52/raw/some/file'
286 286 export_url_with_ext = '/projects/ecookbook/repository/raw/some/file.ext'
287 287 export_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/raw/some/file.ext'
288 288
289 289 to_test = {
290 290 # tickets
291 291 '#3, [#3], (#3) and #3.' => "#{issue_link}, [#{issue_link}], (#{issue_link}) and #{issue_link}.",
292 292 # ticket notes
293 293 '#3-14' => note_link,
294 294 '#3#note-14' => note_link,
295 295 # should not ignore leading zero
296 296 '#03' => '#03',
297 297 # changesets
298 298 'r1' => changeset_link,
299 299 'r1.' => "#{changeset_link}.",
300 300 'r1, r2' => "#{changeset_link}, #{changeset_link2}",
301 301 'r1,r2' => "#{changeset_link},#{changeset_link2}",
302 302 # documents
303 303 'document#1' => document_link,
304 304 'document:"Test document"' => document_link,
305 305 # versions
306 306 'version#2' => version_link,
307 307 'version:1.0' => version_link,
308 308 'version:"1.0"' => version_link,
309 309 # source
310 310 'source:some/file' => link_to('source:some/file', source_url, :class => 'source'),
311 311 'source:/some/file' => link_to('source:/some/file', source_url, :class => 'source'),
312 312 'source:/some/file.' => link_to('source:/some/file', source_url, :class => 'source') + ".",
313 313 'source:/some/file.ext.' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
314 314 'source:/some/file. ' => link_to('source:/some/file', source_url, :class => 'source') + ".",
315 315 'source:/some/file.ext. ' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
316 316 'source:/some/file, ' => link_to('source:/some/file', source_url, :class => 'source') + ",",
317 317 'source:/some/file@52' => link_to('source:/some/file@52', source_url_with_rev, :class => 'source'),
318 318 'source:/some/file.ext@52' => link_to('source:/some/file.ext@52', source_url_with_rev_and_ext, :class => 'source'),
319 319 'source:/some/file#L110' => link_to('source:/some/file#L110', source_url + "#L110", :class => 'source'),
320 320 'source:/some/file.ext#L110' => link_to('source:/some/file.ext#L110', source_url_with_ext + "#L110", :class => 'source'),
321 321 'source:/some/file@52#L110' => link_to('source:/some/file@52#L110', source_url_with_rev + "#L110", :class => 'source'),
322 322 # export
323 323 'export:/some/file' => link_to('export:/some/file', export_url, :class => 'source download'),
324 324 'export:/some/file.ext' => link_to('export:/some/file.ext', export_url_with_ext, :class => 'source download'),
325 325 'export:/some/file@52' => link_to('export:/some/file@52', export_url_with_rev, :class => 'source download'),
326 326 'export:/some/file.ext@52' => link_to('export:/some/file.ext@52', export_url_with_rev_and_ext, :class => 'source download'),
327 327 # forum
328 328 'forum#2' => link_to('Discussion', board_url, :class => 'board'),
329 329 'forum:Discussion' => link_to('Discussion', board_url, :class => 'board'),
330 330 # message
331 331 'message#4' => link_to('Post 2', message_url, :class => 'message'),
332 332 'message#5' => link_to('RE: post 2', message_url.merge(:anchor => 'message-5', :r => 5), :class => 'message'),
333 333 # news
334 334 'news#1' => link_to('eCookbook first release !', news_url, :class => 'news'),
335 335 'news:"eCookbook first release !"' => link_to('eCookbook first release !', news_url, :class => 'news'),
336 336 # project
337 337 'project#3' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
338 338 'project:subproject1' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
339 339 'project:"eCookbook subProject 1"' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
340 340 # not found
341 341 '#0123456789' => '#0123456789',
342 342 # invalid expressions
343 343 'source:' => 'source:',
344 344 # url hash
345 345 "http://foo.bar/FAQ#3" => '<a class="external" href="http://foo.bar/FAQ#3">http://foo.bar/FAQ#3</a>',
346 346 }
347 347 @project = Project.find(1)
348 348 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
349 349 end
350 350
351 351 def test_escaped_redmine_links_should_not_be_parsed
352 352 to_test = [
353 353 '#3.',
354 354 '#3-14.',
355 355 '#3#-note14.',
356 356 'r1',
357 357 'document#1',
358 358 'document:"Test document"',
359 359 'version#2',
360 360 'version:1.0',
361 361 'version:"1.0"',
362 362 'source:/some/file'
363 363 ]
364 364 @project = Project.find(1)
365 365 to_test.each { |text| assert_equal "<p>#{text}</p>", textilizable("!" + text), "#{text} failed" }
366 366 end
367 367
368 368 def test_cross_project_redmine_links
369 369 source_link = link_to('ecookbook:source:/some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']},
370 370 :class => 'source')
371 371
372 372 changeset_link = link_to('ecookbook:r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
373 373 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
374 374
375 375 to_test = {
376 376 # documents
377 377 'document:"Test document"' => 'document:"Test document"',
378 378 'ecookbook:document:"Test document"' => '<a href="/documents/1" class="document">Test document</a>',
379 379 'invalid:document:"Test document"' => 'invalid:document:"Test document"',
380 380 # versions
381 381 'version:"1.0"' => 'version:"1.0"',
382 382 'ecookbook:version:"1.0"' => '<a href="/versions/2" class="version">1.0</a>',
383 383 'invalid:version:"1.0"' => 'invalid:version:"1.0"',
384 384 # changeset
385 385 'r2' => 'r2',
386 386 'ecookbook:r2' => changeset_link,
387 387 'invalid:r2' => 'invalid:r2',
388 388 # source
389 389 'source:/some/file' => 'source:/some/file',
390 390 'ecookbook:source:/some/file' => source_link,
391 391 'invalid:source:/some/file' => 'invalid:source:/some/file',
392 392 }
393 393 @project = Project.find(3)
394 394 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
395 395 end
396 396
397 397 def test_multiple_repositories_redmine_links
398 398 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn1', :url => 'file:///foo/hg')
399 399 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
400 400 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
401 401 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
402 402
403 403 changeset_link = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
404 404 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
405 405 svn_changeset_link = link_to('svn1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn1', :rev => 123},
406 406 :class => 'changeset', :title => '')
407 407 hg_changeset_link = link_to('hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
408 408 :class => 'changeset', :title => '')
409 409
410 410 source_link = link_to('source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
411 411 hg_source_link = link_to('source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
412 412
413 413 to_test = {
414 414 'r2' => changeset_link,
415 415 'svn1|r123' => svn_changeset_link,
416 416 'invalid|r123' => 'invalid|r123',
417 417 'commit:hg1|abcd' => hg_changeset_link,
418 418 'commit:invalid|abcd' => 'commit:invalid|abcd',
419 419 # source
420 420 'source:some/file' => source_link,
421 421 'source:hg1|some/file' => hg_source_link,
422 422 'source:invalid|some/file' => 'source:invalid|some/file',
423 423 }
424 424
425 425 @project = Project.find(1)
426 426 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
427 427 end
428 428
429 429 def test_cross_project_multiple_repositories_redmine_links
430 430 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn1', :url => 'file:///foo/hg')
431 431 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
432 432 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
433 433 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
434 434
435 435 changeset_link = link_to('ecookbook:r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
436 436 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
437 437 svn_changeset_link = link_to('ecookbook:svn1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn1', :rev => 123},
438 438 :class => 'changeset', :title => '')
439 439 hg_changeset_link = link_to('ecookbook:hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
440 440 :class => 'changeset', :title => '')
441 441
442 442 source_link = link_to('ecookbook:source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
443 443 hg_source_link = link_to('ecookbook:source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
444 444
445 445 to_test = {
446 446 'ecookbook:r2' => changeset_link,
447 447 'ecookbook:svn1|r123' => svn_changeset_link,
448 448 'ecookbook:invalid|r123' => 'ecookbook:invalid|r123',
449 449 'ecookbook:commit:hg1|abcd' => hg_changeset_link,
450 450 'ecookbook:commit:invalid|abcd' => 'ecookbook:commit:invalid|abcd',
451 451 'invalid:commit:invalid|abcd' => 'invalid:commit:invalid|abcd',
452 452 # source
453 453 'ecookbook:source:some/file' => source_link,
454 454 'ecookbook:source:hg1|some/file' => hg_source_link,
455 455 'ecookbook:source:invalid|some/file' => 'ecookbook:source:invalid|some/file',
456 456 'invalid:source:invalid|some/file' => 'invalid:source:invalid|some/file',
457 457 }
458 458
459 459 @project = Project.find(3)
460 460 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
461 461 end
462 462
463 463 def test_redmine_links_git_commit
464 464 changeset_link = link_to('abcd',
465 465 {
466 466 :controller => 'repositories',
467 467 :action => 'revision',
468 468 :id => 'subproject1',
469 469 :rev => 'abcd',
470 470 },
471 471 :class => 'changeset', :title => 'test commit')
472 472 to_test = {
473 473 'commit:abcd' => changeset_link,
474 474 }
475 475 @project = Project.find(3)
476 476 r = Repository::Git.create!(:project => @project, :url => '/tmp/test/git')
477 477 assert r
478 478 c = Changeset.new(:repository => r,
479 479 :committed_on => Time.now,
480 480 :revision => 'abcd',
481 481 :scmid => 'abcd',
482 482 :comments => 'test commit')
483 483 assert( c.save )
484 484 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
485 485 end
486 486
487 487 # TODO: Bazaar commit id contains mail address, so it contains '@' and '_'.
488 488 def test_redmine_links_darcs_commit
489 489 changeset_link = link_to('20080308225258-98289-abcd456efg.gz',
490 490 {
491 491 :controller => 'repositories',
492 492 :action => 'revision',
493 493 :id => 'subproject1',
494 494 :rev => '123',
495 495 },
496 496 :class => 'changeset', :title => 'test commit')
497 497 to_test = {
498 498 'commit:20080308225258-98289-abcd456efg.gz' => changeset_link,
499 499 }
500 500 @project = Project.find(3)
501 501 r = Repository::Darcs.create!(
502 502 :project => @project, :url => '/tmp/test/darcs',
503 503 :log_encoding => 'UTF-8')
504 504 assert r
505 505 c = Changeset.new(:repository => r,
506 506 :committed_on => Time.now,
507 507 :revision => '123',
508 508 :scmid => '20080308225258-98289-abcd456efg.gz',
509 509 :comments => 'test commit')
510 510 assert( c.save )
511 511 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
512 512 end
513 513
514 514 def test_redmine_links_mercurial_commit
515 515 changeset_link_rev = link_to('r123',
516 516 {
517 517 :controller => 'repositories',
518 518 :action => 'revision',
519 519 :id => 'subproject1',
520 520 :rev => '123' ,
521 521 },
522 522 :class => 'changeset', :title => 'test commit')
523 523 changeset_link_commit = link_to('abcd',
524 524 {
525 525 :controller => 'repositories',
526 526 :action => 'revision',
527 527 :id => 'subproject1',
528 528 :rev => 'abcd' ,
529 529 },
530 530 :class => 'changeset', :title => 'test commit')
531 531 to_test = {
532 532 'r123' => changeset_link_rev,
533 533 'commit:abcd' => changeset_link_commit,
534 534 }
535 535 @project = Project.find(3)
536 536 r = Repository::Mercurial.create!(:project => @project, :url => '/tmp/test')
537 537 assert r
538 538 c = Changeset.new(:repository => r,
539 539 :committed_on => Time.now,
540 540 :revision => '123',
541 541 :scmid => 'abcd',
542 542 :comments => 'test commit')
543 543 assert( c.save )
544 544 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
545 545 end
546 546
547 547 def test_attachment_links
548 548 attachment_link = link_to('error281.txt', {:controller => 'attachments', :action => 'download', :id => '1'}, :class => 'attachment')
549 549 to_test = {
550 550 'attachment:error281.txt' => attachment_link
551 551 }
552 552 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => Issue.find(3).attachments), "#{text} failed" }
553 553 end
554 554
555 555 def test_wiki_links
556 556 to_test = {
557 557 '[[CookBook documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a>',
558 558 '[[Another page|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a>',
559 559 # title content should be formatted
560 560 '[[Another page|With _styled_ *title*]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">With <em>styled</em> <strong>title</strong></a>',
561 561 '[[Another page|With title containing <strong>HTML entities &amp; markups</strong>]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">With title containing &lt;strong&gt;HTML entities &amp; markups&lt;/strong&gt;</a>',
562 562 # link with anchor
563 563 '[[CookBook documentation#One-section]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">CookBook documentation</a>',
564 564 '[[Another page#anchor|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page#anchor" class="wiki-page">Page</a>',
565 565 # UTF8 anchor
566 566 '[[Another_page#ВСст|ВСст]]' => %|<a href="/projects/ecookbook/wiki/Another_page##{CGI.escape 'ВСст'}" class="wiki-page">ВСст</a>|,
567 567 # page that doesn't exist
568 568 '[[Unknown page]]' => '<a href="/projects/ecookbook/wiki/Unknown_page" class="wiki-page new">Unknown page</a>',
569 569 '[[Unknown page|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page" class="wiki-page new">404</a>',
570 570 # link to another project wiki
571 571 '[[onlinestore:]]' => '<a href="/projects/onlinestore/wiki" class="wiki-page">onlinestore</a>',
572 572 '[[onlinestore:|Wiki]]' => '<a href="/projects/onlinestore/wiki" class="wiki-page">Wiki</a>',
573 573 '[[onlinestore:Start page]]' => '<a href="/projects/onlinestore/wiki/Start_page" class="wiki-page">Start page</a>',
574 574 '[[onlinestore:Start page|Text]]' => '<a href="/projects/onlinestore/wiki/Start_page" class="wiki-page">Text</a>',
575 575 '[[onlinestore:Unknown page]]' => '<a href="/projects/onlinestore/wiki/Unknown_page" class="wiki-page new">Unknown page</a>',
576 576 # striked through link
577 577 '-[[Another page|Page]]-' => '<del><a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a></del>',
578 578 '-[[Another page|Page]] link-' => '<del><a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a> link</del>',
579 579 # escaping
580 580 '![[Another page|Page]]' => '[[Another page|Page]]',
581 581 # project does not exist
582 582 '[[unknowproject:Start]]' => '[[unknowproject:Start]]',
583 583 '[[unknowproject:Start|Page title]]' => '[[unknowproject:Start|Page title]]',
584 584 }
585 585
586 586 @project = Project.find(1)
587 587 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
588 588 end
589 589
590 590 def test_wiki_links_within_local_file_generation_context
591 591
592 592 to_test = {
593 593 # link to a page
594 594 '[[CookBook documentation]]' => '<a href="CookBook_documentation.html" class="wiki-page">CookBook documentation</a>',
595 595 '[[CookBook documentation|documentation]]' => '<a href="CookBook_documentation.html" class="wiki-page">documentation</a>',
596 596 '[[CookBook documentation#One-section]]' => '<a href="CookBook_documentation.html#One-section" class="wiki-page">CookBook documentation</a>',
597 597 '[[CookBook documentation#One-section|documentation]]' => '<a href="CookBook_documentation.html#One-section" class="wiki-page">documentation</a>',
598 598 # page that doesn't exist
599 599 '[[Unknown page]]' => '<a href="Unknown_page.html" class="wiki-page new">Unknown page</a>',
600 600 '[[Unknown page|404]]' => '<a href="Unknown_page.html" class="wiki-page new">404</a>',
601 601 '[[Unknown page#anchor]]' => '<a href="Unknown_page.html#anchor" class="wiki-page new">Unknown page</a>',
602 602 '[[Unknown page#anchor|404]]' => '<a href="Unknown_page.html#anchor" class="wiki-page new">404</a>',
603 603 }
604 604
605 605 @project = Project.find(1)
606 606
607 607 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :local) }
608 608 end
609 609
610 610 def test_wiki_links_within_wiki_page_context
611 611
612 612 page = WikiPage.find_by_title('Another_page' )
613 613
614 614 to_test = {
615 615 # link to another page
616 616 '[[CookBook documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a>',
617 617 '[[CookBook documentation|documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">documentation</a>',
618 618 '[[CookBook documentation#One-section]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">CookBook documentation</a>',
619 619 '[[CookBook documentation#One-section|documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">documentation</a>',
620 620 # link to the current page
621 621 '[[Another page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Another page</a>',
622 622 '[[Another page|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a>',
623 623 '[[Another page#anchor]]' => '<a href="#anchor" class="wiki-page">Another page</a>',
624 624 '[[Another page#anchor|Page]]' => '<a href="#anchor" class="wiki-page">Page</a>',
625 625 # page that doesn't exist
626 626 '[[Unknown page]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page" class="wiki-page new">Unknown page</a>',
627 627 '[[Unknown page|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page" class="wiki-page new">404</a>',
628 628 '[[Unknown page#anchor]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor" class="wiki-page new">Unknown page</a>',
629 629 '[[Unknown page#anchor|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor" class="wiki-page new">404</a>',
630 630 }
631 631
632 632 @project = Project.find(1)
633 633
634 634 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(WikiContent.new( :text => text, :page => page ), :text) }
635 635 end
636 636
637 637 def test_wiki_links_anchor_option_should_prepend_page_title_to_href
638 638
639 639 to_test = {
640 640 # link to a page
641 641 '[[CookBook documentation]]' => '<a href="#CookBook_documentation" class="wiki-page">CookBook documentation</a>',
642 642 '[[CookBook documentation|documentation]]' => '<a href="#CookBook_documentation" class="wiki-page">documentation</a>',
643 643 '[[CookBook documentation#One-section]]' => '<a href="#CookBook_documentation_One-section" class="wiki-page">CookBook documentation</a>',
644 644 '[[CookBook documentation#One-section|documentation]]' => '<a href="#CookBook_documentation_One-section" class="wiki-page">documentation</a>',
645 645 # page that doesn't exist
646 646 '[[Unknown page]]' => '<a href="#Unknown_page" class="wiki-page new">Unknown page</a>',
647 647 '[[Unknown page|404]]' => '<a href="#Unknown_page" class="wiki-page new">404</a>',
648 648 '[[Unknown page#anchor]]' => '<a href="#Unknown_page_anchor" class="wiki-page new">Unknown page</a>',
649 649 '[[Unknown page#anchor|404]]' => '<a href="#Unknown_page_anchor" class="wiki-page new">404</a>',
650 650 }
651 651
652 652 @project = Project.find(1)
653 653
654 654 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :anchor) }
655 655 end
656 656
657 657 def test_html_tags
658 658 to_test = {
659 659 "<div>content</div>" => "<p>&lt;div&gt;content&lt;/div&gt;</p>",
660 660 "<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
661 661 "<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
662 662 # do not escape pre/code tags
663 663 "<pre>\nline 1\nline2</pre>" => "<pre>\nline 1\nline2</pre>",
664 664 "<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
665 665 "<pre><div>content</div></pre>" => "<pre>&lt;div&gt;content&lt;/div&gt;</pre>",
666 666 "HTML comment: <!-- no comments -->" => "<p>HTML comment: &lt;!-- no comments --&gt;</p>",
667 667 "<!-- opening comment" => "<p>&lt;!-- opening comment</p>",
668 668 # remove attributes except class
669 669 "<pre class='foo'>some text</pre>" => "<pre class='foo'>some text</pre>",
670 670 '<pre class="foo">some text</pre>' => '<pre class="foo">some text</pre>',
671 671 "<pre class='foo bar'>some text</pre>" => "<pre class='foo bar'>some text</pre>",
672 672 '<pre class="foo bar">some text</pre>' => '<pre class="foo bar">some text</pre>',
673 673 "<pre onmouseover='alert(1)'>some text</pre>" => "<pre>some text</pre>",
674 674 # xss
675 675 '<pre><code class=""onmouseover="alert(1)">text</code></pre>' => '<pre><code>text</code></pre>',
676 676 '<pre class=""onmouseover="alert(1)">text</pre>' => '<pre>text</pre>',
677 677 }
678 678 to_test.each { |text, result| assert_equal result, textilizable(text) }
679 679 end
680 680
681 681 def test_allowed_html_tags
682 682 to_test = {
683 683 "<pre>preformatted text</pre>" => "<pre>preformatted text</pre>",
684 684 "<notextile>no *textile* formatting</notextile>" => "no *textile* formatting",
685 685 "<notextile>this is <tag>a tag</tag></notextile>" => "this is &lt;tag&gt;a tag&lt;/tag&gt;"
686 686 }
687 687 to_test.each { |text, result| assert_equal result, textilizable(text) }
688 688 end
689 689
690 690 def test_pre_tags
691 691 raw = <<-RAW
692 692 Before
693 693
694 694 <pre>
695 695 <prepared-statement-cache-size>32</prepared-statement-cache-size>
696 696 </pre>
697 697
698 698 After
699 699 RAW
700 700
701 701 expected = <<-EXPECTED
702 702 <p>Before</p>
703 703 <pre>
704 704 &lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;
705 705 </pre>
706 706 <p>After</p>
707 707 EXPECTED
708 708
709 709 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
710 710 end
711 711
712 712 def test_pre_content_should_not_parse_wiki_and_redmine_links
713 713 raw = <<-RAW
714 714 [[CookBook documentation]]
715 715
716 716 #1
717 717
718 718 <pre>
719 719 [[CookBook documentation]]
720 720
721 721 #1
722 722 </pre>
723 723 RAW
724 724
725 725 expected = <<-EXPECTED
726 726 <p><a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a></p>
727 727 <p><a href="/issues/1" class="issue status-1 priority-4 priority-lowest" title="Can&#x27;t print recipes (New)">#1</a></p>
728 728 <pre>
729 729 [[CookBook documentation]]
730 730
731 731 #1
732 732 </pre>
733 733 EXPECTED
734 734
735 735 @project = Project.find(1)
736 736 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
737 737 end
738 738
739 739 def test_non_closing_pre_blocks_should_be_closed
740 740 raw = <<-RAW
741 741 <pre><code>
742 742 RAW
743 743
744 744 expected = <<-EXPECTED
745 745 <pre><code>
746 746 </code></pre>
747 747 EXPECTED
748 748
749 749 @project = Project.find(1)
750 750 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
751 751 end
752 752
753 753 def test_syntax_highlight
754 754 raw = <<-RAW
755 755 <pre><code class="ruby">
756 756 # Some ruby code here
757 757 </code></pre>
758 758 RAW
759 759
760 760 expected = <<-EXPECTED
761 761 <pre><code class="ruby syntaxhl"><span class=\"CodeRay\"><span class="comment"># Some ruby code here</span></span>
762 762 </code></pre>
763 763 EXPECTED
764 764
765 765 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
766 766 end
767 767
768 768 def test_to_path_param
769 769 assert_equal 'test1/test2', to_path_param('test1/test2')
770 770 assert_equal 'test1/test2', to_path_param('/test1/test2/')
771 771 assert_equal 'test1/test2', to_path_param('//test1/test2/')
772 772 assert_equal nil, to_path_param('/')
773 773 end
774 774
775 775 def test_wiki_links_in_tables
776 776 to_test = {"|[[Page|Link title]]|[[Other Page|Other title]]|\n|Cell 21|[[Last page]]|" =>
777 777 '<tr><td><a href="/projects/ecookbook/wiki/Page" class="wiki-page new">Link title</a></td>' +
778 778 '<td><a href="/projects/ecookbook/wiki/Other_Page" class="wiki-page new">Other title</a></td>' +
779 779 '</tr><tr><td>Cell 21</td><td><a href="/projects/ecookbook/wiki/Last_page" class="wiki-page new">Last page</a></td></tr>'
780 780 }
781 781 @project = Project.find(1)
782 782 to_test.each { |text, result| assert_equal "<table>#{result}</table>", textilizable(text).gsub(/[\t\n]/, '') }
783 783 end
784 784
785 785 def test_text_formatting
786 786 to_test = {'*_+bold, italic and underline+_*' => '<strong><em><ins>bold, italic and underline</ins></em></strong>',
787 787 '(_text within parentheses_)' => '(<em>text within parentheses</em>)',
788 788 'a *Humane Web* Text Generator' => 'a <strong>Humane Web</strong> Text Generator',
789 789 'a H *umane* W *eb* T *ext* G *enerator*' => 'a H <strong>umane</strong> W <strong>eb</strong> T <strong>ext</strong> G <strong>enerator</strong>',
790 790 'a *H* umane *W* eb *T* ext *G* enerator' => 'a <strong>H</strong> umane <strong>W</strong> eb <strong>T</strong> ext <strong>G</strong> enerator',
791 791 }
792 792 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
793 793 end
794 794
795 795 def test_wiki_horizontal_rule
796 796 assert_equal '<hr />', textilizable('---')
797 797 assert_equal '<p>Dashes: ---</p>', textilizable('Dashes: ---')
798 798 end
799 799
800 800 def test_footnotes
801 801 raw = <<-RAW
802 802 This is some text[1].
803 803
804 804 fn1. This is the foot note
805 805 RAW
806 806
807 807 expected = <<-EXPECTED
808 808 <p>This is some text<sup><a href=\"#fn1\">1</a></sup>.</p>
809 809 <p id="fn1" class="footnote"><sup>1</sup> This is the foot note</p>
810 810 EXPECTED
811 811
812 812 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
813 813 end
814 814
815 815 def test_headings
816 816 raw = 'h1. Some heading'
817 817 expected = %|<a name="Some-heading"></a>\n<h1 >Some heading<a href="#Some-heading" class="wiki-anchor">&para;</a></h1>|
818 818
819 819 assert_equal expected, textilizable(raw)
820 820 end
821 821
822 822 def test_headings_with_special_chars
823 823 # This test makes sure that the generated anchor names match the expected
824 824 # ones even if the heading text contains unconventional characters
825 825 raw = 'h1. Some heading related to version 0.5'
826 826 anchor = sanitize_anchor_name("Some-heading-related-to-version-0.5")
827 827 expected = %|<a name="#{anchor}"></a>\n<h1 >Some heading related to version 0.5<a href="##{anchor}" class="wiki-anchor">&para;</a></h1>|
828 828
829 829 assert_equal expected, textilizable(raw)
830 830 end
831 831
832 832 def test_headings_in_wiki_single_page_export_should_be_prepended_with_page_title
833 833 page = WikiPage.new( :title => 'Page Title', :wiki_id => 1 )
834 834 content = WikiContent.new( :text => 'h1. Some heading', :page => page )
835 835
836 836 expected = %|<a name="Page_Title_Some-heading"></a>\n<h1 >Some heading<a href="#Page_Title_Some-heading" class="wiki-anchor">&para;</a></h1>|
837 837
838 838 assert_equal expected, textilizable(content, :text, :wiki_links => :anchor )
839 839 end
840 840
841 841 def test_table_of_content
842 842 raw = <<-RAW
843 843 {{toc}}
844 844
845 845 h1. Title
846 846
847 847 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
848 848
849 849 h2. Subtitle with a [[Wiki]] link
850 850
851 851 Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
852 852
853 853 h2. Subtitle with [[Wiki|another Wiki]] link
854 854
855 855 h2. Subtitle with %{color:red}red text%
856 856
857 857 <pre>
858 858 some code
859 859 </pre>
860 860
861 861 h3. Subtitle with *some* _modifiers_
862 862
863 863 h3. Subtitle with @inline code@
864 864
865 865 h1. Another title
866 866
867 867 h3. An "Internet link":http://www.redmine.org/ inside subtitle
868 868
869 869 h2. "Project Name !/attachments/1234/logo_small.gif! !/attachments/5678/logo_2.png!":/projects/projectname/issues
870 870
871 871 RAW
872 872
873 873 expected = '<ul class="toc">' +
874 874 '<li><a href="#Title">Title</a>' +
875 875 '<ul>' +
876 876 '<li><a href="#Subtitle-with-a-Wiki-link">Subtitle with a Wiki link</a></li>' +
877 877 '<li><a href="#Subtitle-with-another-Wiki-link">Subtitle with another Wiki link</a></li>' +
878 878 '<li><a href="#Subtitle-with-red-text">Subtitle with red text</a>' +
879 879 '<ul>' +
880 880 '<li><a href="#Subtitle-with-some-modifiers">Subtitle with some modifiers</a></li>' +
881 881 '<li><a href="#Subtitle-with-inline-code">Subtitle with inline code</a></li>' +
882 882 '</ul>' +
883 883 '</li>' +
884 884 '</ul>' +
885 885 '</li>' +
886 886 '<li><a href="#Another-title">Another title</a>' +
887 887 '<ul>' +
888 888 '<li>' +
889 889 '<ul>' +
890 890 '<li><a href="#An-Internet-link-inside-subtitle">An Internet link inside subtitle</a></li>' +
891 891 '</ul>' +
892 892 '</li>' +
893 893 '<li><a href="#Project-Name">Project Name</a></li>' +
894 894 '</ul>' +
895 895 '</li>' +
896 896 '</ul>'
897 897
898 898 @project = Project.find(1)
899 899 assert textilizable(raw).gsub("\n", "").include?(expected)
900 900 end
901 901
902 902 def test_table_of_content_should_generate_unique_anchors
903 903 raw = <<-RAW
904 904 {{toc}}
905 905
906 906 h1. Title
907 907
908 908 h2. Subtitle
909 909
910 910 h2. Subtitle
911 911 RAW
912 912
913 913 expected = '<ul class="toc">' +
914 914 '<li><a href="#Title">Title</a>' +
915 915 '<ul>' +
916 916 '<li><a href="#Subtitle">Subtitle</a></li>' +
917 917 '<li><a href="#Subtitle-2">Subtitle</a></li>'
918 918 '</ul>'
919 919 '</li>' +
920 920 '</ul>'
921 921
922 922 @project = Project.find(1)
923 923 result = textilizable(raw).gsub("\n", "")
924 924 assert_include expected, result
925 925 assert_include '<a name="Subtitle">', result
926 926 assert_include '<a name="Subtitle-2">', result
927 927 end
928 928
929 929 def test_table_of_content_should_contain_included_page_headings
930 930 raw = <<-RAW
931 931 {{toc}}
932 932
933 933 h1. Included
934 934
935 935 {{include(Child_1)}}
936 936 RAW
937 937
938 938 expected = '<ul class="toc">' +
939 939 '<li><a href="#Included">Included</a></li>' +
940 940 '<li><a href="#Child-page-1">Child page 1</a></li>' +
941 941 '</ul>'
942 942
943 943 @project = Project.find(1)
944 944 assert textilizable(raw).gsub("\n", "").include?(expected)
945 945 end
946 946
947 947 def test_section_edit_links
948 948 raw = <<-RAW
949 949 h1. Title
950 950
951 951 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
952 952
953 953 h2. Subtitle with a [[Wiki]] link
954 954
955 955 h2. Subtitle with *some* _modifiers_
956 956
957 957 h2. Subtitle with @inline code@
958 958
959 959 <pre>
960 960 some code
961 961
962 962 h2. heading inside pre
963 963
964 964 <h2>html heading inside pre</h2>
965 965 </pre>
966 966
967 967 h2. Subtitle after pre tag
968 968 RAW
969 969
970 970 @project = Project.find(1)
971 971 set_language_if_valid 'en'
972 972 result = textilizable(raw, :edit_section_links => {:controller => 'wiki', :action => 'edit', :project_id => '1', :id => 'Test'}).gsub("\n", "")
973 973
974 974 # heading that contains inline code
975 975 assert_match Regexp.new('<div class="contextual" title="Edit this section">' +
976 976 '<a href="/projects/1/wiki/Test/edit\?section=4"><img alt="Edit" src="/images/edit.png(\?\d+)?" /></a></div>' +
977 977 '<a name="Subtitle-with-inline-code"></a>' +
978 978 '<h2 >Subtitle with <code>inline code</code><a href="#Subtitle-with-inline-code" class="wiki-anchor">&para;</a></h2>'),
979 979 result
980 980
981 981 # last heading
982 982 assert_match Regexp.new('<div class="contextual" title="Edit this section">' +
983 983 '<a href="/projects/1/wiki/Test/edit\?section=5"><img alt="Edit" src="/images/edit.png(\?\d+)?" /></a></div>' +
984 984 '<a name="Subtitle-after-pre-tag"></a>' +
985 985 '<h2 >Subtitle after pre tag<a href="#Subtitle-after-pre-tag" class="wiki-anchor">&para;</a></h2>'),
986 986 result
987 987 end
988 988
989 989 def test_default_formatter
990 990 with_settings :text_formatting => 'unknown' do
991 991 text = 'a *link*: http://www.example.net/'
992 992 assert_equal '<p>a *link*: <a class="external" href="http://www.example.net/">http://www.example.net/</a></p>', textilizable(text)
993 993 end
994 994 end
995 995
996 996 def test_due_date_distance_in_words
997 997 to_test = { Date.today => 'Due in 0 days',
998 998 Date.today + 1 => 'Due in 1 day',
999 999 Date.today + 100 => 'Due in about 3 months',
1000 1000 Date.today + 20000 => 'Due in over 54 years',
1001 1001 Date.today - 1 => '1 day late',
1002 1002 Date.today - 100 => 'about 3 months late',
1003 1003 Date.today - 20000 => 'over 54 years late',
1004 1004 }
1005 1005 ::I18n.locale = :en
1006 1006 to_test.each do |date, expected|
1007 1007 assert_equal expected, due_date_distance_in_words(date)
1008 1008 end
1009 1009 end
1010 1010
1011 1011 def test_avatar_enabled
1012 1012 with_settings :gravatar_enabled => '1' do
1013 1013 assert avatar(User.find_by_mail('jsmith@somenet.foo')).include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
1014 1014 assert avatar('jsmith <jsmith@somenet.foo>').include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
1015 1015 # Default size is 50
1016 1016 assert avatar('jsmith <jsmith@somenet.foo>').include?('size=50')
1017 1017 assert avatar('jsmith <jsmith@somenet.foo>', :size => 24).include?('size=24')
1018 1018 # Non-avatar options should be considered html options
1019 1019 assert avatar('jsmith <jsmith@somenet.foo>', :title => 'John Smith').include?('title="John Smith"')
1020 1020 # The default class of the img tag should be gravatar
1021 1021 assert avatar('jsmith <jsmith@somenet.foo>').include?('class="gravatar"')
1022 1022 assert !avatar('jsmith <jsmith@somenet.foo>', :class => 'picture').include?('class="gravatar"')
1023 1023 assert_nil avatar('jsmith')
1024 1024 assert_nil avatar(nil)
1025 1025 end
1026 1026 end
1027 1027
1028 1028 def test_avatar_disabled
1029 1029 with_settings :gravatar_enabled => '0' do
1030 1030 assert_equal '', avatar(User.find_by_mail('jsmith@somenet.foo'))
1031 1031 end
1032 1032 end
1033 1033
1034 1034 def test_link_to_user
1035 1035 user = User.find(2)
1036 1036 assert_equal '<a href="/users/2" class="user active">John Smith</a>', link_to_user(user)
1037 1037 end
1038 1038
1039 1039 def test_link_to_user_should_not_link_to_locked_user
1040 1040 with_current_user nil do
1041 1041 user = User.find(5)
1042 1042 assert user.locked?
1043 1043 assert_equal 'Dave2 Lopper2', link_to_user(user)
1044 1044 end
1045 1045 end
1046 1046
1047 1047 def test_link_to_user_should_link_to_locked_user_if_current_user_is_admin
1048 1048 with_current_user User.find(1) do
1049 1049 user = User.find(5)
1050 1050 assert user.locked?
1051 1051 assert_equal '<a href="/users/5" class="user locked">Dave2 Lopper2</a>', link_to_user(user)
1052 1052 end
1053 1053 end
1054 1054
1055 1055 def test_link_to_user_should_not_link_to_anonymous
1056 1056 user = User.anonymous
1057 1057 assert user.anonymous?
1058 1058 t = link_to_user(user)
1059 1059 assert_equal ::I18n.t(:label_user_anonymous), t
1060 1060 end
1061 1061
1062 1062 def test_link_to_project
1063 1063 project = Project.find(1)
1064 1064 assert_equal %(<a href="/projects/ecookbook">eCookbook</a>),
1065 1065 link_to_project(project)
1066 1066 assert_equal %(<a href="/projects/ecookbook/settings">eCookbook</a>),
1067 1067 link_to_project(project, :action => 'settings')
1068 1068 assert_equal %(<a href="http://test.host/projects/ecookbook?jump=blah">eCookbook</a>),
1069 1069 link_to_project(project, {:only_path => false, :jump => 'blah'})
1070 1070 assert_equal %(<a href="/projects/ecookbook/settings" class="project">eCookbook</a>),
1071 1071 link_to_project(project, {:action => 'settings'}, :class => "project")
1072 1072 end
1073 1073
1074 1074 def test_link_to_legacy_project_with_numerical_identifier_should_use_id
1075 1075 # numeric identifier are no longer allowed
1076 1076 Project.update_all "identifier=25", "id=1"
1077 1077
1078 1078 assert_equal '<a href="/projects/1">eCookbook</a>',
1079 1079 link_to_project(Project.find(1))
1080 1080 end
1081 1081
1082 1082 def test_principals_options_for_select_with_users
1083 1083 User.current = nil
1084 1084 users = [User.find(2), User.find(4)]
1085 1085 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>),
1086 1086 principals_options_for_select(users)
1087 1087 end
1088 1088
1089 1089 def test_principals_options_for_select_with_selected
1090 1090 User.current = nil
1091 1091 users = [User.find(2), User.find(4)]
1092 1092 assert_equal %(<option value="2">John Smith</option><option value="4" selected="selected">Robert Hill</option>),
1093 1093 principals_options_for_select(users, User.find(4))
1094 1094 end
1095 1095
1096 1096 def test_principals_options_for_select_with_users_and_groups
1097 1097 User.current = nil
1098 1098 users = [User.find(2), Group.find(11), User.find(4), Group.find(10)]
1099 1099 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>) +
1100 1100 %(<optgroup label="Groups"><option value="10">A Team</option><option value="11">B Team</option></optgroup>),
1101 1101 principals_options_for_select(users)
1102 1102 end
1103 1103
1104 1104 def test_principals_options_for_select_with_empty_collection
1105 1105 assert_equal '', principals_options_for_select([])
1106 1106 end
1107 1107
1108 1108 def test_principals_options_for_select_should_include_me_option_when_current_user_is_in_collection
1109 1109 users = [User.find(2), User.find(4)]
1110 1110 User.current = User.find(4)
1111 1111 assert_include '<option value="4">&lt;&lt; me &gt;&gt;</option>', principals_options_for_select(users)
1112 1112 end
1113 1113
1114 1114 def test_stylesheet_link_tag_should_pick_the_default_stylesheet
1115 1115 assert_match 'href="/stylesheets/styles.css"', stylesheet_link_tag("styles")
1116 1116 end
1117 1117
1118 1118 def test_stylesheet_link_tag_for_plugin_should_pick_the_plugin_stylesheet
1119 1119 assert_match 'href="/plugin_assets/foo/stylesheets/styles.css"', stylesheet_link_tag("styles", :plugin => :foo)
1120 1120 end
1121 1121
1122 1122 def test_image_tag_should_pick_the_default_image
1123 1123 assert_match 'src="/images/image.png"', image_tag("image.png")
1124 1124 end
1125 1125
1126 1126 def test_image_tag_should_pick_the_theme_image_if_it_exists
1127 1127 theme = Redmine::Themes.themes.last
1128 1128 theme.images << 'image.png'
1129 1129
1130 1130 with_settings :ui_theme => theme.id do
1131 1131 assert_match %|src="/themes/#{theme.dir}/images/image.png"|, image_tag("image.png")
1132 1132 assert_match %|src="/images/other.png"|, image_tag("other.png")
1133 1133 end
1134 1134 ensure
1135 1135 theme.images.delete 'image.png'
1136 1136 end
1137 1137
1138 1138 def test_image_tag_sfor_plugin_should_pick_the_plugin_image
1139 1139 assert_match 'src="/plugin_assets/foo/images/image.png"', image_tag("image.png", :plugin => :foo)
1140 1140 end
1141 1141
1142 1142 def test_javascript_include_tag_should_pick_the_default_javascript
1143 1143 assert_match 'src="/javascripts/scripts.js"', javascript_include_tag("scripts")
1144 1144 end
1145 1145
1146 1146 def test_javascript_include_tag_for_plugin_should_pick_the_plugin_javascript
1147 1147 assert_match 'src="/plugin_assets/foo/javascripts/scripts.js"', javascript_include_tag("scripts", :plugin => :foo)
1148 1148 end
1149 1149
1150 1150 def test_per_page_links_should_show_usefull_values
1151 1151 set_language_if_valid 'en'
1152 1152 stubs(:link_to).returns("[link]")
1153 1153
1154 1154 with_settings :per_page_options => '10, 25, 50, 100' do
1155 1155 assert_nil per_page_links(10, 3)
1156 1156 assert_nil per_page_links(25, 3)
1157 1157 assert_equal "Per page: 10, [link]", per_page_links(10, 22)
1158 1158 assert_equal "Per page: [link], 25", per_page_links(25, 22)
1159 1159 assert_equal "Per page: [link], [link], 50", per_page_links(50, 22)
1160 1160 assert_equal "Per page: [link], 25, [link]", per_page_links(25, 26)
1161 1161 assert_equal "Per page: [link], 25, [link], [link]", per_page_links(25, 120)
1162 1162 end
1163 1163 end
1164 1164 end
@@ -1,377 +1,377
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 IssueNestedSetTest < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :members, :member_roles, :roles,
22 22 :trackers, :projects_trackers,
23 23 :versions,
24 24 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 25 :enumerations,
26 26 :issues,
27 27 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 28 :time_entries
29 29
30 30 def test_create_root_issue
31 31 issue1 = Issue.generate!
32 32 issue2 = Issue.generate!
33 33 issue1.reload
34 34 issue2.reload
35 35
36 36 assert_equal [issue1.id, nil, 1, 2], [issue1.root_id, issue1.parent_id, issue1.lft, issue1.rgt]
37 37 assert_equal [issue2.id, nil, 1, 2], [issue2.root_id, issue2.parent_id, issue2.lft, issue2.rgt]
38 38 end
39 39
40 40 def test_create_child_issue
41 41 parent = Issue.generate!
42 42 child = Issue.generate!(:parent_issue_id => parent.id)
43 43 parent.reload
44 44 child.reload
45 45
46 46 assert_equal [parent.id, nil, 1, 4], [parent.root_id, parent.parent_id, parent.lft, parent.rgt]
47 47 assert_equal [parent.id, parent.id, 2, 3], [child.root_id, child.parent_id, child.lft, child.rgt]
48 48 end
49 49
50 50 def test_creating_a_child_in_a_subproject_should_validate
51 51 issue = Issue.generate!
52 52 child = Issue.new(:project_id => 3, :tracker_id => 2, :author_id => 1,
53 53 :subject => 'child', :parent_issue_id => issue.id)
54 54 assert_save child
55 55 assert_equal issue, child.reload.parent
56 56 end
57 57
58 58 def test_creating_a_child_in_an_invalid_project_should_not_validate
59 59 issue = Issue.generate!
60 60 child = Issue.new(:project_id => 2, :tracker_id => 1, :author_id => 1,
61 61 :subject => 'child', :parent_issue_id => issue.id)
62 62 assert !child.save
63 63 assert_not_nil child.errors[:parent_issue_id]
64 64 end
65 65
66 66 def test_move_a_root_to_child
67 67 parent1 = Issue.generate!
68 68 parent2 = Issue.generate!
69 69 child = Issue.generate!(:parent_issue_id => parent1.id)
70 70
71 71 parent2.parent_issue_id = parent1.id
72 72 parent2.save!
73 73 child.reload
74 74 parent1.reload
75 75 parent2.reload
76 76
77 77 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
78 78 assert_equal [parent1.id, 4, 5], [parent2.root_id, parent2.lft, parent2.rgt]
79 79 assert_equal [parent1.id, 2, 3], [child.root_id, child.lft, child.rgt]
80 80 end
81 81
82 82 def test_move_a_child_to_root
83 83 parent1 = Issue.generate!
84 84 parent2 = Issue.generate!
85 85 child = Issue.generate!(:parent_issue_id => parent1.id)
86 86
87 87 child.parent_issue_id = nil
88 88 child.save!
89 89 child.reload
90 90 parent1.reload
91 91 parent2.reload
92 92
93 93 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
94 94 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
95 95 assert_equal [child.id, 1, 2], [child.root_id, child.lft, child.rgt]
96 96 end
97 97
98 98 def test_move_a_child_to_another_issue
99 99 parent1 = Issue.generate!
100 100 parent2 = Issue.generate!
101 101 child = Issue.generate!(:parent_issue_id => parent1.id)
102 102
103 103 child.parent_issue_id = parent2.id
104 104 child.save!
105 105 child.reload
106 106 parent1.reload
107 107 parent2.reload
108 108
109 109 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
110 110 assert_equal [parent2.id, 1, 4], [parent2.root_id, parent2.lft, parent2.rgt]
111 111 assert_equal [parent2.id, 2, 3], [child.root_id, child.lft, child.rgt]
112 112 end
113 113
114 114 def test_move_a_child_with_descendants_to_another_issue
115 115 parent1 = Issue.generate!
116 116 parent2 = Issue.generate!
117 117 child = Issue.generate!(:parent_issue_id => parent1.id)
118 118 grandchild = Issue.generate!(:parent_issue_id => child.id)
119 119
120 120 parent1.reload
121 121 parent2.reload
122 122 child.reload
123 123 grandchild.reload
124 124
125 125 assert_equal [parent1.id, 1, 6], [parent1.root_id, parent1.lft, parent1.rgt]
126 126 assert_equal [parent2.id, 1, 2], [parent2.root_id, parent2.lft, parent2.rgt]
127 127 assert_equal [parent1.id, 2, 5], [child.root_id, child.lft, child.rgt]
128 128 assert_equal [parent1.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
129 129
130 130 child.reload.parent_issue_id = parent2.id
131 131 child.save!
132 132 child.reload
133 133 grandchild.reload
134 134 parent1.reload
135 135 parent2.reload
136 136
137 137 assert_equal [parent1.id, 1, 2], [parent1.root_id, parent1.lft, parent1.rgt]
138 138 assert_equal [parent2.id, 1, 6], [parent2.root_id, parent2.lft, parent2.rgt]
139 139 assert_equal [parent2.id, 2, 5], [child.root_id, child.lft, child.rgt]
140 140 assert_equal [parent2.id, 3, 4], [grandchild.root_id, grandchild.lft, grandchild.rgt]
141 141 end
142 142
143 143 def test_move_a_child_with_descendants_to_another_project
144 144 parent1 = Issue.generate!
145 145 child = Issue.generate!(:parent_issue_id => parent1.id)
146 146 grandchild = Issue.generate!(:parent_issue_id => child.id)
147 147
148 148 child.reload
149 149 child.project = Project.find(2)
150 150 assert child.save
151 151 child.reload
152 152 grandchild.reload
153 153 parent1.reload
154 154
155 155 assert_equal [1, parent1.id, 1, 2], [parent1.project_id, parent1.root_id, parent1.lft, parent1.rgt]
156 156 assert_equal [2, child.id, 1, 4], [child.project_id, child.root_id, child.lft, child.rgt]
157 157 assert_equal [2, child.id, 2, 3], [grandchild.project_id, grandchild.root_id, grandchild.lft, grandchild.rgt]
158 158 end
159 159
160 160 def test_moving_an_issue_to_a_descendant_should_not_validate
161 161 parent1 = Issue.generate!
162 162 parent2 = Issue.generate!
163 163 child = Issue.generate!(:parent_issue_id => parent1.id)
164 164 grandchild = Issue.generate!(:parent_issue_id => child.id)
165 165
166 166 child.reload
167 167 child.parent_issue_id = grandchild.id
168 168 assert !child.save
169 169 assert_not_nil child.errors[:parent_issue_id]
170 170 end
171 171
172 172 def test_moving_an_issue_should_keep_valid_relations_only
173 173 issue1 = Issue.generate!
174 174 issue2 = Issue.generate!
175 175 issue3 = Issue.generate!(:parent_issue_id => issue2.id)
176 176 issue4 = Issue.generate!
177 177 r1 = IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
178 178 r2 = IssueRelation.create!(:issue_from => issue1, :issue_to => issue3, :relation_type => IssueRelation::TYPE_PRECEDES)
179 179 r3 = IssueRelation.create!(:issue_from => issue2, :issue_to => issue4, :relation_type => IssueRelation::TYPE_PRECEDES)
180 180 issue2.reload
181 181 issue2.parent_issue_id = issue1.id
182 182 issue2.save!
183 183 assert !IssueRelation.exists?(r1.id)
184 184 assert !IssueRelation.exists?(r2.id)
185 185 assert IssueRelation.exists?(r3.id)
186 186 end
187 187
188 188 def test_destroy_should_destroy_children
189 189 issue1 = Issue.generate!
190 190 issue2 = Issue.generate!
191 191 issue3 = Issue.generate!(:parent_issue_id => issue2.id)
192 192 issue4 = Issue.generate!(:parent_issue_id => issue1.id)
193 193
194 194 issue3.init_journal(User.find(2))
195 195 issue3.subject = 'child with journal'
196 196 issue3.save!
197 197
198 198 assert_difference 'Issue.count', -2 do
199 199 assert_difference 'Journal.count', -1 do
200 200 assert_difference 'JournalDetail.count', -1 do
201 201 Issue.find(issue2.id).destroy
202 202 end
203 203 end
204 204 end
205 205
206 206 issue1.reload
207 207 issue4.reload
208 208 assert !Issue.exists?(issue2.id)
209 209 assert !Issue.exists?(issue3.id)
210 210 assert_equal [issue1.id, 1, 4], [issue1.root_id, issue1.lft, issue1.rgt]
211 211 assert_equal [issue1.id, 2, 3], [issue4.root_id, issue4.lft, issue4.rgt]
212 212 end
213 213
214 214 def test_destroy_child_should_update_parent
215 215 issue = Issue.generate!
216 216 child1 = Issue.generate!(:parent_issue_id => issue.id)
217 217 child2 = Issue.generate!(:parent_issue_id => issue.id)
218 218
219 219 issue.reload
220 220 assert_equal [issue.id, 1, 6], [issue.root_id, issue.lft, issue.rgt]
221 221
222 222 child2.reload.destroy
223 223
224 224 issue.reload
225 225 assert_equal [issue.id, 1, 4], [issue.root_id, issue.lft, issue.rgt]
226 226 end
227 227
228 228 def test_destroy_parent_issue_updated_during_children_destroy
229 229 parent = Issue.generate!
230 230 Issue.generate!(:start_date => Date.today, :parent_issue_id => parent.id)
231 231 Issue.generate!(:start_date => 2.days.from_now, :parent_issue_id => parent.id)
232 232
233 233 assert_difference 'Issue.count', -3 do
234 234 Issue.find(parent.id).destroy
235 235 end
236 236 end
237 237
238 238 def test_destroy_child_issue_with_children
239 239 root = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'root')
240 240 child = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => root.id)
241 241 leaf = Issue.create!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'leaf', :parent_issue_id => child.id)
242 242 leaf.init_journal(User.find(2))
243 243 leaf.subject = 'leaf with journal'
244 244 leaf.save!
245 245
246 246 assert_difference 'Issue.count', -2 do
247 247 assert_difference 'Journal.count', -1 do
248 248 assert_difference 'JournalDetail.count', -1 do
249 249 Issue.find(child.id).destroy
250 250 end
251 251 end
252 252 end
253 253
254 254 root = Issue.find(root.id)
255 255 assert root.leaf?, "Root issue is not a leaf (lft: #{root.lft}, rgt: #{root.rgt})"
256 256 end
257 257
258 258 def test_destroy_issue_with_grand_child
259 259 parent = Issue.generate!
260 260 issue = Issue.generate!(:parent_issue_id => parent.id)
261 261 child = Issue.generate!(:parent_issue_id => issue.id)
262 262 grandchild1 = Issue.generate!(:parent_issue_id => child.id)
263 263 grandchild2 = Issue.generate!(:parent_issue_id => child.id)
264 264
265 265 assert_difference 'Issue.count', -4 do
266 266 Issue.find(issue.id).destroy
267 267 parent.reload
268 268 assert_equal [1, 2], [parent.lft, parent.rgt]
269 269 end
270 270 end
271 271
272 272 def test_parent_priority_should_be_the_highest_child_priority
273 273 parent = Issue.generate!(:priority => IssuePriority.find_by_name('Normal'))
274 274 # Create children
275 275 child1 = Issue.generate!(:priority => IssuePriority.find_by_name('High'), :parent_issue_id => parent.id)
276 276 assert_equal 'High', parent.reload.priority.name
277 277 child2 = Issue.generate!(:priority => IssuePriority.find_by_name('Immediate'), :parent_issue_id => child1.id)
278 278 assert_equal 'Immediate', child1.reload.priority.name
279 279 assert_equal 'Immediate', parent.reload.priority.name
280 280 child3 = Issue.generate!(:priority => IssuePriority.find_by_name('Low'), :parent_issue_id => parent.id)
281 281 assert_equal 'Immediate', parent.reload.priority.name
282 282 # Destroy a child
283 283 child1.destroy
284 284 assert_equal 'Low', parent.reload.priority.name
285 285 # Update a child
286 286 child3.reload.priority = IssuePriority.find_by_name('Normal')
287 287 child3.save!
288 288 assert_equal 'Normal', parent.reload.priority.name
289 289 end
290 290
291 291 def test_parent_dates_should_be_lowest_start_and_highest_due_dates
292 292 parent = Issue.generate!
293 293 Issue.generate!(:start_date => '2010-01-25', :due_date => '2010-02-15', :parent_issue_id => parent.id)
294 294 Issue.generate!( :due_date => '2010-02-13', :parent_issue_id => parent.id)
295 295 Issue.generate!(:start_date => '2010-02-01', :due_date => '2010-02-22', :parent_issue_id => parent.id)
296 296 parent.reload
297 297 assert_equal Date.parse('2010-01-25'), parent.start_date
298 298 assert_equal Date.parse('2010-02-22'), parent.due_date
299 299 end
300 300
301 301 def test_parent_done_ratio_should_be_average_done_ratio_of_leaves
302 302 parent = Issue.generate!
303 303 Issue.generate!(:done_ratio => 20, :parent_issue_id => parent.id)
304 304 assert_equal 20, parent.reload.done_ratio
305 305 Issue.generate!(:done_ratio => 70, :parent_issue_id => parent.id)
306 306 assert_equal 45, parent.reload.done_ratio
307 307
308 308 child = Issue.generate!(:done_ratio => 0, :parent_issue_id => parent.id)
309 309 assert_equal 30, parent.reload.done_ratio
310 310
311 311 Issue.generate!(:done_ratio => 30, :parent_issue_id => child.id)
312 312 assert_equal 30, child.reload.done_ratio
313 313 assert_equal 40, parent.reload.done_ratio
314 314 end
315 315
316 316 def test_parent_done_ratio_should_be_weighted_by_estimated_times_if_any
317 317 parent = Issue.generate!
318 318 Issue.generate!(:estimated_hours => 10, :done_ratio => 20, :parent_issue_id => parent.id)
319 319 assert_equal 20, parent.reload.done_ratio
320 320 Issue.generate!(:estimated_hours => 20, :done_ratio => 50, :parent_issue_id => parent.id)
321 321 assert_equal (50 * 20 + 20 * 10) / 30, parent.reload.done_ratio
322 322 end
323 323
324 324 def test_parent_estimate_should_be_sum_of_leaves
325 325 parent = Issue.generate!
326 326 Issue.generate!(:estimated_hours => nil, :parent_issue_id => parent.id)
327 327 assert_equal nil, parent.reload.estimated_hours
328 328 Issue.generate!(:estimated_hours => 5, :parent_issue_id => parent.id)
329 329 assert_equal 5, parent.reload.estimated_hours
330 330 Issue.generate!(:estimated_hours => 7, :parent_issue_id => parent.id)
331 331 assert_equal 12, parent.reload.estimated_hours
332 332 end
333 333
334 334 def test_move_parent_updates_old_parent_attributes
335 335 first_parent = Issue.generate!
336 336 second_parent = Issue.generate!
337 337 child = Issue.generate!(:estimated_hours => 5, :parent_issue_id => first_parent.id)
338 338 assert_equal 5, first_parent.reload.estimated_hours
339 339 child.update_attributes(:estimated_hours => 7, :parent_issue_id => second_parent.id)
340 340 assert_equal 7, second_parent.reload.estimated_hours
341 341 assert_nil first_parent.reload.estimated_hours
342 342 end
343 343
344 344 def test_reschuling_a_parent_should_reschedule_subtasks
345 345 parent = Issue.generate!
346 346 c1 = Issue.generate!(:start_date => '2010-05-12', :due_date => '2010-05-18', :parent_issue_id => parent.id)
347 347 c2 = Issue.generate!(:start_date => '2010-06-03', :due_date => '2010-06-10', :parent_issue_id => parent.id)
348 348 parent.reload
349 349 parent.reschedule_on!(Date.parse('2010-06-02'))
350 350 c1.reload
351 351 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-08')], [c1.start_date, c1.due_date]
352 352 c2.reload
353 353 assert_equal [Date.parse('2010-06-03'), Date.parse('2010-06-10')], [c2.start_date, c2.due_date] # no change
354 354 parent.reload
355 355 assert_equal [Date.parse('2010-06-02'), Date.parse('2010-06-10')], [parent.start_date, parent.due_date]
356 356 end
357 357
358 358 def test_project_copy_should_copy_issue_tree
359 359 p = Project.create!(:name => 'Tree copy', :identifier => 'tree-copy', :tracker_ids => [1, 2])
360 360 i1 = Issue.generate!(:project => p, :subject => 'i1')
361 361 i2 = Issue.generate!(:project => p, :subject => 'i2', :parent_issue_id => i1.id)
362 362 i3 = Issue.generate!(:project => p, :subject => 'i3', :parent_issue_id => i1.id)
363 363 i4 = Issue.generate!(:project => p, :subject => 'i4', :parent_issue_id => i2.id)
364 364 i5 = Issue.generate!(:project => p, :subject => 'i5')
365 365 c = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1, 2])
366 366 c.copy(p, :only => 'issues')
367 367 c.reload
368 368
369 369 assert_equal 5, c.issues.count
370 ic1, ic2, ic3, ic4, ic5 = c.issues.find(:all, :order => 'subject')
370 ic1, ic2, ic3, ic4, ic5 = c.issues.order('subject').all
371 371 assert ic1.root?
372 372 assert_equal ic1, ic2.parent
373 373 assert_equal ic1, ic3.parent
374 374 assert_equal ic2, ic4.parent
375 375 assert ic5.root?
376 376 end
377 377 end
@@ -1,1212 +1,1212
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 ProjectTest < ActiveSupport::TestCase
21 21 fixtures :projects, :trackers, :issue_statuses, :issues,
22 22 :journals, :journal_details,
23 23 :enumerations, :users, :issue_categories,
24 24 :projects_trackers,
25 25 :custom_fields,
26 26 :custom_fields_projects,
27 27 :custom_fields_trackers,
28 28 :custom_values,
29 29 :roles,
30 30 :member_roles,
31 31 :members,
32 32 :enabled_modules,
33 33 :workflows,
34 34 :versions,
35 35 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
36 36 :groups_users,
37 37 :boards, :messages,
38 38 :repositories,
39 39 :news, :comments,
40 40 :documents
41 41
42 42 def setup
43 43 @ecookbook = Project.find(1)
44 44 @ecookbook_sub1 = Project.find(3)
45 45 set_tmp_attachments_directory
46 46 User.current = nil
47 47 end
48 48
49 49 def test_truth
50 50 assert_kind_of Project, @ecookbook
51 51 assert_equal "eCookbook", @ecookbook.name
52 52 end
53 53
54 54 def test_default_attributes
55 55 with_settings :default_projects_public => '1' do
56 56 assert_equal true, Project.new.is_public
57 57 assert_equal false, Project.new(:is_public => false).is_public
58 58 end
59 59
60 60 with_settings :default_projects_public => '0' do
61 61 assert_equal false, Project.new.is_public
62 62 assert_equal true, Project.new(:is_public => true).is_public
63 63 end
64 64
65 65 with_settings :sequential_project_identifiers => '1' do
66 66 assert !Project.new.identifier.blank?
67 67 assert Project.new(:identifier => '').identifier.blank?
68 68 end
69 69
70 70 with_settings :sequential_project_identifiers => '0' do
71 71 assert Project.new.identifier.blank?
72 72 assert !Project.new(:identifier => 'test').blank?
73 73 end
74 74
75 75 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
76 76 assert_equal ['issue_tracking', 'repository'], Project.new.enabled_module_names
77 77 end
78 78
79 79 assert_equal Tracker.all.sort, Project.new.trackers.sort
80 80 assert_equal Tracker.find(1, 3).sort, Project.new(:tracker_ids => [1, 3]).trackers.sort
81 81 end
82 82
83 83 def test_update
84 84 assert_equal "eCookbook", @ecookbook.name
85 85 @ecookbook.name = "eCook"
86 86 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
87 87 @ecookbook.reload
88 88 assert_equal "eCook", @ecookbook.name
89 89 end
90 90
91 91 def test_validate_identifier
92 92 to_test = {"abc" => true,
93 93 "ab12" => true,
94 94 "ab-12" => true,
95 95 "ab_12" => true,
96 96 "12" => false,
97 97 "new" => false}
98 98
99 99 to_test.each do |identifier, valid|
100 100 p = Project.new
101 101 p.identifier = identifier
102 102 p.valid?
103 103 if valid
104 104 assert p.errors['identifier'].blank?, "identifier #{identifier} was not valid"
105 105 else
106 106 assert p.errors['identifier'].present?, "identifier #{identifier} was valid"
107 107 end
108 108 end
109 109 end
110 110
111 111 def test_identifier_should_not_be_frozen_for_a_new_project
112 112 assert_equal false, Project.new.identifier_frozen?
113 113 end
114 114
115 115 def test_identifier_should_not_be_frozen_for_a_saved_project_with_blank_identifier
116 116 Project.update_all(["identifier = ''"], "id = 1")
117 117
118 118 assert_equal false, Project.find(1).identifier_frozen?
119 119 end
120 120
121 121 def test_identifier_should_be_frozen_for_a_saved_project_with_valid_identifier
122 122 assert_equal true, Project.find(1).identifier_frozen?
123 123 end
124 124
125 125 def test_members_should_be_active_users
126 126 Project.all.each do |project|
127 127 assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) }
128 128 end
129 129 end
130 130
131 131 def test_users_should_be_active_users
132 132 Project.all.each do |project|
133 133 assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) }
134 134 end
135 135 end
136 136
137 137 def test_open_scope_on_issues_association
138 138 assert_kind_of Issue, Project.find(1).issues.open.first
139 139 end
140 140
141 141 def test_archive
142 142 user = @ecookbook.members.first.user
143 143 @ecookbook.archive
144 144 @ecookbook.reload
145 145
146 146 assert !@ecookbook.active?
147 147 assert @ecookbook.archived?
148 148 assert !user.projects.include?(@ecookbook)
149 149 # Subproject are also archived
150 150 assert !@ecookbook.children.empty?
151 151 assert @ecookbook.descendants.active.empty?
152 152 end
153 153
154 154 def test_archive_should_fail_if_versions_are_used_by_non_descendant_projects
155 155 # Assign an issue of a project to a version of a child project
156 156 Issue.find(4).update_attribute :fixed_version_id, 4
157 157
158 158 assert_no_difference "Project.count(:all, :conditions => 'status = #{Project::STATUS_ARCHIVED}')" do
159 159 assert_equal false, @ecookbook.archive
160 160 end
161 161 @ecookbook.reload
162 162 assert @ecookbook.active?
163 163 end
164 164
165 165 def test_unarchive
166 166 user = @ecookbook.members.first.user
167 167 @ecookbook.archive
168 168 # A subproject of an archived project can not be unarchived
169 169 assert !@ecookbook_sub1.unarchive
170 170
171 171 # Unarchive project
172 172 assert @ecookbook.unarchive
173 173 @ecookbook.reload
174 174 assert @ecookbook.active?
175 175 assert !@ecookbook.archived?
176 176 assert user.projects.include?(@ecookbook)
177 177 # Subproject can now be unarchived
178 178 @ecookbook_sub1.reload
179 179 assert @ecookbook_sub1.unarchive
180 180 end
181 181
182 182 def test_destroy
183 183 # 2 active members
184 184 assert_equal 2, @ecookbook.members.size
185 185 # and 1 is locked
186 assert_equal 3, Member.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).size
186 assert_equal 3, Member.where('project_id = ?', @ecookbook.id).all.size
187 187 # some boards
188 188 assert @ecookbook.boards.any?
189 189
190 190 @ecookbook.destroy
191 191 # make sure that the project non longer exists
192 192 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
193 193 # make sure related data was removed
194 194 assert_nil Member.first(:conditions => {:project_id => @ecookbook.id})
195 195 assert_nil Board.first(:conditions => {:project_id => @ecookbook.id})
196 196 assert_nil Issue.first(:conditions => {:project_id => @ecookbook.id})
197 197 end
198 198
199 199 def test_destroy_should_destroy_subtasks
200 200 issues = (0..2).to_a.map {Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :subject => 'test')}
201 201 issues[0].update_attribute :parent_issue_id, issues[1].id
202 202 issues[2].update_attribute :parent_issue_id, issues[1].id
203 203 assert_equal 2, issues[1].children.count
204 204
205 205 assert_nothing_raised do
206 206 Project.find(1).destroy
207 207 end
208 208 assert Issue.find_all_by_id(issues.map(&:id)).empty?
209 209 end
210 210
211 211 def test_destroying_root_projects_should_clear_data
212 212 Project.roots.each do |root|
213 213 root.destroy
214 214 end
215 215
216 216 assert_equal 0, Project.count, "Projects were not deleted: #{Project.all.inspect}"
217 217 assert_equal 0, Member.count, "Members were not deleted: #{Member.all.inspect}"
218 218 assert_equal 0, MemberRole.count
219 219 assert_equal 0, Issue.count
220 220 assert_equal 0, Journal.count
221 221 assert_equal 0, JournalDetail.count
222 222 assert_equal 0, Attachment.count, "Attachments were not deleted: #{Attachment.all.inspect}"
223 223 assert_equal 0, EnabledModule.count
224 224 assert_equal 0, IssueCategory.count
225 225 assert_equal 0, IssueRelation.count
226 226 assert_equal 0, Board.count
227 227 assert_equal 0, Message.count
228 228 assert_equal 0, News.count
229 229 assert_equal 0, Query.count(:conditions => "project_id IS NOT NULL")
230 230 assert_equal 0, Repository.count
231 231 assert_equal 0, Changeset.count
232 232 assert_equal 0, Change.count
233 233 assert_equal 0, Comment.count
234 234 assert_equal 0, TimeEntry.count
235 235 assert_equal 0, Version.count
236 236 assert_equal 0, Watcher.count
237 237 assert_equal 0, Wiki.count
238 238 assert_equal 0, WikiPage.count
239 239 assert_equal 0, WikiContent.count
240 240 assert_equal 0, WikiContent::Version.count
241 241 assert_equal 0, Project.connection.select_all("SELECT * FROM projects_trackers").size
242 242 assert_equal 0, Project.connection.select_all("SELECT * FROM custom_fields_projects").size
243 243 assert_equal 0, CustomValue.count(:conditions => {:customized_type => ['Project', 'Issue', 'TimeEntry', 'Version']})
244 244 end
245 245
246 246 def test_move_an_orphan_project_to_a_root_project
247 247 sub = Project.find(2)
248 248 sub.set_parent! @ecookbook
249 249 assert_equal @ecookbook.id, sub.parent.id
250 250 @ecookbook.reload
251 251 assert_equal 4, @ecookbook.children.size
252 252 end
253 253
254 254 def test_move_an_orphan_project_to_a_subproject
255 255 sub = Project.find(2)
256 256 assert sub.set_parent!(@ecookbook_sub1)
257 257 end
258 258
259 259 def test_move_a_root_project_to_a_project
260 260 sub = @ecookbook
261 261 assert sub.set_parent!(Project.find(2))
262 262 end
263 263
264 264 def test_should_not_move_a_project_to_its_children
265 265 sub = @ecookbook
266 266 assert !(sub.set_parent!(Project.find(3)))
267 267 end
268 268
269 269 def test_set_parent_should_add_roots_in_alphabetical_order
270 270 ProjectCustomField.delete_all
271 271 Project.delete_all
272 272 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
273 273 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
274 274 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
275 275 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
276 276
277 277 assert_equal 4, Project.count
278 278 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
279 279 end
280 280
281 281 def test_set_parent_should_add_children_in_alphabetical_order
282 282 ProjectCustomField.delete_all
283 283 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
284 284 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
285 285 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
286 286 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
287 287 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
288 288
289 289 parent.reload
290 290 assert_equal 4, parent.children.size
291 291 assert_equal parent.children.all.sort_by(&:name), parent.children.all
292 292 end
293 293
294 294 def test_set_parent_should_update_issue_fixed_version_associations_when_a_fixed_version_is_moved_out_of_the_hierarchy
295 295 # Parent issue with a hierarchy project's fixed version
296 296 parent_issue = Issue.find(1)
297 297 parent_issue.update_attribute(:fixed_version_id, 4)
298 298 parent_issue.reload
299 299 assert_equal 4, parent_issue.fixed_version_id
300 300
301 301 # Should keep fixed versions for the issues
302 302 issue_with_local_fixed_version = Issue.find(5)
303 303 issue_with_local_fixed_version.update_attribute(:fixed_version_id, 4)
304 304 issue_with_local_fixed_version.reload
305 305 assert_equal 4, issue_with_local_fixed_version.fixed_version_id
306 306
307 307 # Local issue with hierarchy fixed_version
308 308 issue_with_hierarchy_fixed_version = Issue.find(13)
309 309 issue_with_hierarchy_fixed_version.update_attribute(:fixed_version_id, 6)
310 310 issue_with_hierarchy_fixed_version.reload
311 311 assert_equal 6, issue_with_hierarchy_fixed_version.fixed_version_id
312 312
313 313 # Move project out of the issue's hierarchy
314 314 moved_project = Project.find(3)
315 315 moved_project.set_parent!(Project.find(2))
316 316 parent_issue.reload
317 317 issue_with_local_fixed_version.reload
318 318 issue_with_hierarchy_fixed_version.reload
319 319
320 320 assert_equal 4, issue_with_local_fixed_version.fixed_version_id, "Fixed version was not keep on an issue local to the moved project"
321 321 assert_equal nil, issue_with_hierarchy_fixed_version.fixed_version_id, "Fixed version is still set after moving the Project out of the hierarchy where the version is defined in"
322 322 assert_equal nil, parent_issue.fixed_version_id, "Fixed version is still set after moving the Version out of the hierarchy for the issue."
323 323 end
324 324
325 325 def test_parent
326 326 p = Project.find(6).parent
327 327 assert p.is_a?(Project)
328 328 assert_equal 5, p.id
329 329 end
330 330
331 331 def test_ancestors
332 332 a = Project.find(6).ancestors
333 333 assert a.first.is_a?(Project)
334 334 assert_equal [1, 5], a.collect(&:id)
335 335 end
336 336
337 337 def test_root
338 338 r = Project.find(6).root
339 339 assert r.is_a?(Project)
340 340 assert_equal 1, r.id
341 341 end
342 342
343 343 def test_children
344 344 c = Project.find(1).children
345 345 assert c.first.is_a?(Project)
346 346 assert_equal [5, 3, 4], c.collect(&:id)
347 347 end
348 348
349 349 def test_descendants
350 350 d = Project.find(1).descendants
351 351 assert d.first.is_a?(Project)
352 352 assert_equal [5, 6, 3, 4], d.collect(&:id)
353 353 end
354 354
355 355 def test_allowed_parents_should_be_empty_for_non_member_user
356 356 Role.non_member.add_permission!(:add_project)
357 357 user = User.find(9)
358 358 assert user.memberships.empty?
359 359 User.current = user
360 360 assert Project.new.allowed_parents.compact.empty?
361 361 end
362 362
363 363 def test_allowed_parents_with_add_subprojects_permission
364 364 Role.find(1).remove_permission!(:add_project)
365 365 Role.find(1).add_permission!(:add_subprojects)
366 366 User.current = User.find(2)
367 367 # new project
368 368 assert !Project.new.allowed_parents.include?(nil)
369 369 assert Project.new.allowed_parents.include?(Project.find(1))
370 370 # existing root project
371 371 assert Project.find(1).allowed_parents.include?(nil)
372 372 # existing child
373 373 assert Project.find(3).allowed_parents.include?(Project.find(1))
374 374 assert !Project.find(3).allowed_parents.include?(nil)
375 375 end
376 376
377 377 def test_allowed_parents_with_add_project_permission
378 378 Role.find(1).add_permission!(:add_project)
379 379 Role.find(1).remove_permission!(:add_subprojects)
380 380 User.current = User.find(2)
381 381 # new project
382 382 assert Project.new.allowed_parents.include?(nil)
383 383 assert !Project.new.allowed_parents.include?(Project.find(1))
384 384 # existing root project
385 385 assert Project.find(1).allowed_parents.include?(nil)
386 386 # existing child
387 387 assert Project.find(3).allowed_parents.include?(Project.find(1))
388 388 assert Project.find(3).allowed_parents.include?(nil)
389 389 end
390 390
391 391 def test_allowed_parents_with_add_project_and_subprojects_permission
392 392 Role.find(1).add_permission!(:add_project)
393 393 Role.find(1).add_permission!(:add_subprojects)
394 394 User.current = User.find(2)
395 395 # new project
396 396 assert Project.new.allowed_parents.include?(nil)
397 397 assert Project.new.allowed_parents.include?(Project.find(1))
398 398 # existing root project
399 399 assert Project.find(1).allowed_parents.include?(nil)
400 400 # existing child
401 401 assert Project.find(3).allowed_parents.include?(Project.find(1))
402 402 assert Project.find(3).allowed_parents.include?(nil)
403 403 end
404 404
405 405 def test_users_by_role
406 406 users_by_role = Project.find(1).users_by_role
407 407 assert_kind_of Hash, users_by_role
408 408 role = Role.find(1)
409 409 assert_kind_of Array, users_by_role[role]
410 410 assert users_by_role[role].include?(User.find(2))
411 411 end
412 412
413 413 def test_rolled_up_trackers
414 414 parent = Project.find(1)
415 415 parent.trackers = Tracker.find([1,2])
416 416 child = parent.children.find(3)
417 417
418 418 assert_equal [1, 2], parent.tracker_ids
419 419 assert_equal [2, 3], child.trackers.collect(&:id)
420 420
421 421 assert_kind_of Tracker, parent.rolled_up_trackers.first
422 422 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
423 423
424 424 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
425 425 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
426 426 end
427 427
428 428 def test_rolled_up_trackers_should_ignore_archived_subprojects
429 429 parent = Project.find(1)
430 430 parent.trackers = Tracker.find([1,2])
431 431 child = parent.children.find(3)
432 432 child.trackers = Tracker.find([1,3])
433 433 parent.children.each(&:archive)
434 434
435 435 assert_equal [1,2], parent.rolled_up_trackers.collect(&:id)
436 436 end
437 437
438 438 context "#rolled_up_versions" do
439 439 setup do
440 440 @project = Project.generate!
441 441 @parent_version_1 = Version.generate!(:project => @project)
442 442 @parent_version_2 = Version.generate!(:project => @project)
443 443 end
444 444
445 445 should "include the versions for the current project" do
446 446 assert_same_elements [@parent_version_1, @parent_version_2], @project.rolled_up_versions
447 447 end
448 448
449 449 should "include versions for a subproject" do
450 450 @subproject = Project.generate!
451 451 @subproject.set_parent!(@project)
452 452 @subproject_version = Version.generate!(:project => @subproject)
453 453
454 454 assert_same_elements [
455 455 @parent_version_1,
456 456 @parent_version_2,
457 457 @subproject_version
458 458 ], @project.rolled_up_versions
459 459 end
460 460
461 461 should "include versions for a sub-subproject" do
462 462 @subproject = Project.generate!
463 463 @subproject.set_parent!(@project)
464 464 @sub_subproject = Project.generate!
465 465 @sub_subproject.set_parent!(@subproject)
466 466 @sub_subproject_version = Version.generate!(:project => @sub_subproject)
467 467
468 468 @project.reload
469 469
470 470 assert_same_elements [
471 471 @parent_version_1,
472 472 @parent_version_2,
473 473 @sub_subproject_version
474 474 ], @project.rolled_up_versions
475 475 end
476 476
477 477 should "only check active projects" do
478 478 @subproject = Project.generate!
479 479 @subproject.set_parent!(@project)
480 480 @subproject_version = Version.generate!(:project => @subproject)
481 481 assert @subproject.archive
482 482
483 483 @project.reload
484 484
485 485 assert !@subproject.active?
486 486 assert_same_elements [@parent_version_1, @parent_version_2], @project.rolled_up_versions
487 487 end
488 488 end
489 489
490 490 def test_shared_versions_none_sharing
491 491 p = Project.find(5)
492 492 v = Version.create!(:name => 'none_sharing', :project => p, :sharing => 'none')
493 493 assert p.shared_versions.include?(v)
494 494 assert !p.children.first.shared_versions.include?(v)
495 495 assert !p.root.shared_versions.include?(v)
496 496 assert !p.siblings.first.shared_versions.include?(v)
497 497 assert !p.root.siblings.first.shared_versions.include?(v)
498 498 end
499 499
500 500 def test_shared_versions_descendants_sharing
501 501 p = Project.find(5)
502 502 v = Version.create!(:name => 'descendants_sharing', :project => p, :sharing => 'descendants')
503 503 assert p.shared_versions.include?(v)
504 504 assert p.children.first.shared_versions.include?(v)
505 505 assert !p.root.shared_versions.include?(v)
506 506 assert !p.siblings.first.shared_versions.include?(v)
507 507 assert !p.root.siblings.first.shared_versions.include?(v)
508 508 end
509 509
510 510 def test_shared_versions_hierarchy_sharing
511 511 p = Project.find(5)
512 512 v = Version.create!(:name => 'hierarchy_sharing', :project => p, :sharing => 'hierarchy')
513 513 assert p.shared_versions.include?(v)
514 514 assert p.children.first.shared_versions.include?(v)
515 515 assert p.root.shared_versions.include?(v)
516 516 assert !p.siblings.first.shared_versions.include?(v)
517 517 assert !p.root.siblings.first.shared_versions.include?(v)
518 518 end
519 519
520 520 def test_shared_versions_tree_sharing
521 521 p = Project.find(5)
522 522 v = Version.create!(:name => 'tree_sharing', :project => p, :sharing => 'tree')
523 523 assert p.shared_versions.include?(v)
524 524 assert p.children.first.shared_versions.include?(v)
525 525 assert p.root.shared_versions.include?(v)
526 526 assert p.siblings.first.shared_versions.include?(v)
527 527 assert !p.root.siblings.first.shared_versions.include?(v)
528 528 end
529 529
530 530 def test_shared_versions_system_sharing
531 531 p = Project.find(5)
532 532 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
533 533 assert p.shared_versions.include?(v)
534 534 assert p.children.first.shared_versions.include?(v)
535 535 assert p.root.shared_versions.include?(v)
536 536 assert p.siblings.first.shared_versions.include?(v)
537 537 assert p.root.siblings.first.shared_versions.include?(v)
538 538 end
539 539
540 540 def test_shared_versions
541 541 parent = Project.find(1)
542 542 child = parent.children.find(3)
543 543 private_child = parent.children.find(5)
544 544
545 545 assert_equal [1,2,3], parent.version_ids.sort
546 546 assert_equal [4], child.version_ids
547 547 assert_equal [6], private_child.version_ids
548 548 assert_equal [7], Version.find_all_by_sharing('system').collect(&:id)
549 549
550 550 assert_equal 6, parent.shared_versions.size
551 551 parent.shared_versions.each do |version|
552 552 assert_kind_of Version, version
553 553 end
554 554
555 555 assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort
556 556 end
557 557
558 558 def test_shared_versions_should_ignore_archived_subprojects
559 559 parent = Project.find(1)
560 560 child = parent.children.find(3)
561 561 child.archive
562 562 parent.reload
563 563
564 564 assert_equal [1,2,3], parent.version_ids.sort
565 565 assert_equal [4], child.version_ids
566 566 assert !parent.shared_versions.collect(&:id).include?(4)
567 567 end
568 568
569 569 def test_shared_versions_visible_to_user
570 570 user = User.find(3)
571 571 parent = Project.find(1)
572 572 child = parent.children.find(5)
573 573
574 574 assert_equal [1,2,3], parent.version_ids.sort
575 575 assert_equal [6], child.version_ids
576 576
577 577 versions = parent.shared_versions.visible(user)
578 578
579 579 assert_equal 4, versions.size
580 580 versions.each do |version|
581 581 assert_kind_of Version, version
582 582 end
583 583
584 584 assert !versions.collect(&:id).include?(6)
585 585 end
586 586
587 587 def test_shared_versions_for_new_project_should_include_system_shared_versions
588 588 p = Project.find(5)
589 589 v = Version.create!(:name => 'system_sharing', :project => p, :sharing => 'system')
590 590
591 591 assert_include v, Project.new.shared_versions
592 592 end
593 593
594 594 def test_next_identifier
595 595 ProjectCustomField.delete_all
596 596 Project.create!(:name => 'last', :identifier => 'p2008040')
597 597 assert_equal 'p2008041', Project.next_identifier
598 598 end
599 599
600 600 def test_next_identifier_first_project
601 601 Project.delete_all
602 602 assert_nil Project.next_identifier
603 603 end
604 604
605 605 def test_enabled_module_names
606 606 with_settings :default_projects_modules => ['issue_tracking', 'repository'] do
607 607 project = Project.new
608 608
609 609 project.enabled_module_names = %w(issue_tracking news)
610 610 assert_equal %w(issue_tracking news), project.enabled_module_names.sort
611 611 end
612 612 end
613 613
614 614 context "enabled_modules" do
615 615 setup do
616 616 @project = Project.find(1)
617 617 end
618 618
619 619 should "define module by names and preserve ids" do
620 620 # Remove one module
621 621 modules = @project.enabled_modules.slice(0..-2)
622 622 assert modules.any?
623 623 assert_difference 'EnabledModule.count', -1 do
624 624 @project.enabled_module_names = modules.collect(&:name)
625 625 end
626 626 @project.reload
627 627 # Ids should be preserved
628 628 assert_equal @project.enabled_module_ids.sort, modules.collect(&:id).sort
629 629 end
630 630
631 631 should "enable a module" do
632 632 @project.enabled_module_names = []
633 633 @project.reload
634 634 assert_equal [], @project.enabled_module_names
635 635 #with string
636 636 @project.enable_module!("issue_tracking")
637 637 assert_equal ["issue_tracking"], @project.enabled_module_names
638 638 #with symbol
639 639 @project.enable_module!(:gantt)
640 640 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
641 641 #don't add a module twice
642 642 @project.enable_module!("issue_tracking")
643 643 assert_equal ["issue_tracking", "gantt"], @project.enabled_module_names
644 644 end
645 645
646 646 should "disable a module" do
647 647 #with string
648 648 assert @project.enabled_module_names.include?("issue_tracking")
649 649 @project.disable_module!("issue_tracking")
650 650 assert ! @project.reload.enabled_module_names.include?("issue_tracking")
651 651 #with symbol
652 652 assert @project.enabled_module_names.include?("gantt")
653 653 @project.disable_module!(:gantt)
654 654 assert ! @project.reload.enabled_module_names.include?("gantt")
655 655 #with EnabledModule object
656 656 first_module = @project.enabled_modules.first
657 657 @project.disable_module!(first_module)
658 658 assert ! @project.reload.enabled_module_names.include?(first_module.name)
659 659 end
660 660 end
661 661
662 662 def test_enabled_module_names_should_not_recreate_enabled_modules
663 663 project = Project.find(1)
664 664 # Remove one module
665 665 modules = project.enabled_modules.slice(0..-2)
666 666 assert modules.any?
667 667 assert_difference 'EnabledModule.count', -1 do
668 668 project.enabled_module_names = modules.collect(&:name)
669 669 end
670 670 project.reload
671 671 # Ids should be preserved
672 672 assert_equal project.enabled_module_ids.sort, modules.collect(&:id).sort
673 673 end
674 674
675 675 def test_copy_from_existing_project
676 676 source_project = Project.find(1)
677 677 copied_project = Project.copy_from(1)
678 678
679 679 assert copied_project
680 680 # Cleared attributes
681 681 assert copied_project.id.blank?
682 682 assert copied_project.name.blank?
683 683 assert copied_project.identifier.blank?
684 684
685 685 # Duplicated attributes
686 686 assert_equal source_project.description, copied_project.description
687 687 assert_equal source_project.enabled_modules, copied_project.enabled_modules
688 688 assert_equal source_project.trackers, copied_project.trackers
689 689
690 690 # Default attributes
691 691 assert_equal 1, copied_project.status
692 692 end
693 693
694 694 def test_activities_should_use_the_system_activities
695 695 project = Project.find(1)
696 assert_equal project.activities, TimeEntryActivity.find(:all, :conditions => {:active => true} )
696 assert_equal project.activities, TimeEntryActivity.where(:active => true).all
697 697 end
698 698
699 699
700 700 def test_activities_should_use_the_project_specific_activities
701 701 project = Project.find(1)
702 702 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project})
703 703 assert overridden_activity.save!
704 704
705 705 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
706 706 end
707 707
708 708 def test_activities_should_not_include_the_inactive_project_specific_activities
709 709 project = Project.find(1)
710 710 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
711 711 assert overridden_activity.save!
712 712
713 713 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity found"
714 714 end
715 715
716 716 def test_activities_should_not_include_project_specific_activities_from_other_projects
717 717 project = Project.find(1)
718 718 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(2)})
719 719 assert overridden_activity.save!
720 720
721 721 assert !project.activities.include?(overridden_activity), "Project specific Activity found on a different project"
722 722 end
723 723
724 724 def test_activities_should_handle_nils
725 725 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.find(:first)})
726 726 TimeEntryActivity.delete_all
727 727
728 728 # No activities
729 729 project = Project.find(1)
730 730 assert project.activities.empty?
731 731
732 732 # No system, one overridden
733 733 assert overridden_activity.save!
734 734 project.reload
735 735 assert_equal [overridden_activity], project.activities
736 736 end
737 737
738 738 def test_activities_should_override_system_activities_with_project_activities
739 739 project = Project.find(1)
740 740 parent_activity = TimeEntryActivity.find(:first)
741 741 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => parent_activity})
742 742 assert overridden_activity.save!
743 743
744 744 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
745 745 assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
746 746 end
747 747
748 748 def test_activities_should_include_inactive_activities_if_specified
749 749 project = Project.find(1)
750 750 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
751 751 assert overridden_activity.save!
752 752
753 753 assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
754 754 end
755 755
756 756 test 'activities should not include active System activities if the project has an override that is inactive' do
757 757 project = Project.find(1)
758 758 system_activity = TimeEntryActivity.find_by_name('Design')
759 759 assert system_activity.active?
760 760 overridden_activity = TimeEntryActivity.create!(:name => "Project", :project => project, :parent => system_activity, :active => false)
761 761 assert overridden_activity.save!
762 762
763 763 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity not found"
764 764 assert !project.activities.include?(system_activity), "System activity found when the project has an inactive override"
765 765 end
766 766
767 767 def test_close_completed_versions
768 768 Version.update_all("status = 'open'")
769 769 project = Project.find(1)
770 770 assert_not_nil project.versions.detect {|v| v.completed? && v.status == 'open'}
771 771 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
772 772 project.close_completed_versions
773 773 project.reload
774 774 assert_nil project.versions.detect {|v| v.completed? && v.status != 'closed'}
775 775 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
776 776 end
777 777
778 778 context "Project#copy" do
779 779 setup do
780 780 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
781 781 Project.destroy_all :identifier => "copy-test"
782 782 @source_project = Project.find(2)
783 783 @project = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
784 784 @project.trackers = @source_project.trackers
785 785 @project.enabled_module_names = @source_project.enabled_modules.collect(&:name)
786 786 end
787 787
788 788 should "copy issues" do
789 789 @source_project.issues << Issue.generate!(:status => IssueStatus.find_by_name('Closed'),
790 790 :subject => "copy issue status",
791 791 :tracker_id => 1,
792 792 :assigned_to_id => 2,
793 793 :project_id => @source_project.id)
794 794 assert @project.valid?
795 795 assert @project.issues.empty?
796 796 assert @project.copy(@source_project)
797 797
798 798 assert_equal @source_project.issues.size, @project.issues.size
799 799 @project.issues.each do |issue|
800 800 assert issue.valid?
801 801 assert ! issue.assigned_to.blank?
802 802 assert_equal @project, issue.project
803 803 end
804 804
805 805 copied_issue = @project.issues.first(:conditions => {:subject => "copy issue status"})
806 806 assert copied_issue
807 807 assert copied_issue.status
808 808 assert_equal "Closed", copied_issue.status.name
809 809 end
810 810
811 811 should "copy issues assigned to a locked version" do
812 812 User.current = User.find(1)
813 813 assigned_version = Version.generate!(:name => "Assigned Issues")
814 814 @source_project.versions << assigned_version
815 815 Issue.generate!(:project => @source_project,
816 816 :fixed_version_id => assigned_version.id,
817 817 :subject => "copy issues assigned to a locked version")
818 818 assigned_version.update_attribute :status, 'locked'
819 819
820 820 assert @project.copy(@source_project)
821 821 @project.reload
822 822 copied_issue = @project.issues.first(:conditions => {:subject => "copy issues assigned to a locked version"})
823 823
824 824 assert copied_issue
825 825 assert copied_issue.fixed_version
826 826 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
827 827 assert_equal 'locked', copied_issue.fixed_version.status
828 828 end
829 829
830 830 should "change the new issues to use the copied version" do
831 831 User.current = User.find(1)
832 832 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open')
833 833 @source_project.versions << assigned_version
834 834 assert_equal 3, @source_project.versions.size
835 835 Issue.generate!(:project => @source_project,
836 836 :fixed_version_id => assigned_version.id,
837 837 :subject => "change the new issues to use the copied version")
838 838
839 839 assert @project.copy(@source_project)
840 840 @project.reload
841 841 copied_issue = @project.issues.first(:conditions => {:subject => "change the new issues to use the copied version"})
842 842
843 843 assert copied_issue
844 844 assert copied_issue.fixed_version
845 845 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
846 846 assert_not_equal assigned_version.id, copied_issue.fixed_version.id # Different record
847 847 end
848 848
849 849 should "keep target shared versions from other project" do
850 850 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open', :project_id => 1, :sharing => 'system')
851 851 issue = Issue.generate!(:project => @source_project,
852 852 :fixed_version => assigned_version,
853 853 :subject => "keep target shared versions")
854 854
855 855 assert @project.copy(@source_project)
856 856 @project.reload
857 857 copied_issue = @project.issues.first(:conditions => {:subject => "keep target shared versions"})
858 858
859 859 assert copied_issue
860 860 assert_equal assigned_version, copied_issue.fixed_version
861 861 end
862 862
863 863 should "copy issue relations" do
864 864 Setting.cross_project_issue_relations = '1'
865 865
866 866 second_issue = Issue.generate!(:status_id => 5,
867 867 :subject => "copy issue relation",
868 868 :tracker_id => 1,
869 869 :assigned_to_id => 2,
870 870 :project_id => @source_project.id)
871 871 source_relation = IssueRelation.create!(:issue_from => Issue.find(4),
872 872 :issue_to => second_issue,
873 873 :relation_type => "relates")
874 874 source_relation_cross_project = IssueRelation.create!(:issue_from => Issue.find(1),
875 875 :issue_to => second_issue,
876 876 :relation_type => "duplicates")
877 877
878 878 assert @project.copy(@source_project)
879 879 assert_equal @source_project.issues.count, @project.issues.count
880 880 copied_issue = @project.issues.find_by_subject("Issue on project 2") # Was #4
881 881 copied_second_issue = @project.issues.find_by_subject("copy issue relation")
882 882
883 883 # First issue with a relation on project
884 884 assert_equal 1, copied_issue.relations.size, "Relation not copied"
885 885 copied_relation = copied_issue.relations.first
886 886 assert_equal "relates", copied_relation.relation_type
887 887 assert_equal copied_second_issue.id, copied_relation.issue_to_id
888 888 assert_not_equal source_relation.id, copied_relation.id
889 889
890 890 # Second issue with a cross project relation
891 891 assert_equal 2, copied_second_issue.relations.size, "Relation not copied"
892 892 copied_relation = copied_second_issue.relations.select {|r| r.relation_type == 'duplicates'}.first
893 893 assert_equal "duplicates", copied_relation.relation_type
894 894 assert_equal 1, copied_relation.issue_from_id, "Cross project relation not kept"
895 895 assert_not_equal source_relation_cross_project.id, copied_relation.id
896 896 end
897 897
898 898 should "copy issue attachments" do
899 899 issue = Issue.generate!(:subject => "copy with attachment", :tracker_id => 1, :project_id => @source_project.id)
900 900 Attachment.create!(:container => issue, :file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 1)
901 901 @source_project.issues << issue
902 902 assert @project.copy(@source_project)
903 903
904 904 copied_issue = @project.issues.first(:conditions => {:subject => "copy with attachment"})
905 905 assert_not_nil copied_issue
906 906 assert_equal 1, copied_issue.attachments.count, "Attachment not copied"
907 907 assert_equal "testfile.txt", copied_issue.attachments.first.filename
908 908 end
909 909
910 910 should "copy memberships" do
911 911 assert @project.valid?
912 912 assert @project.members.empty?
913 913 assert @project.copy(@source_project)
914 914
915 915 assert_equal @source_project.memberships.size, @project.memberships.size
916 916 @project.memberships.each do |membership|
917 917 assert membership
918 918 assert_equal @project, membership.project
919 919 end
920 920 end
921 921
922 922 should "copy memberships with groups and additional roles" do
923 923 group = Group.create!(:lastname => "Copy group")
924 924 user = User.find(7)
925 925 group.users << user
926 926 # group role
927 927 Member.create!(:project_id => @source_project.id, :principal => group, :role_ids => [2])
928 928 member = Member.find_by_user_id_and_project_id(user.id, @source_project.id)
929 929 # additional role
930 930 member.role_ids = [1]
931 931
932 932 assert @project.copy(@source_project)
933 933 member = Member.find_by_user_id_and_project_id(user.id, @project.id)
934 934 assert_not_nil member
935 935 assert_equal [1, 2], member.role_ids.sort
936 936 end
937 937
938 938 should "copy project specific queries" do
939 939 assert @project.valid?
940 940 assert @project.queries.empty?
941 941 assert @project.copy(@source_project)
942 942
943 943 assert_equal @source_project.queries.size, @project.queries.size
944 944 @project.queries.each do |query|
945 945 assert query
946 946 assert_equal @project, query.project
947 947 end
948 948 assert_equal @source_project.queries.map(&:user_id).sort, @project.queries.map(&:user_id).sort
949 949 end
950 950
951 951 should "copy versions" do
952 952 @source_project.versions << Version.generate!
953 953 @source_project.versions << Version.generate!
954 954
955 955 assert @project.versions.empty?
956 956 assert @project.copy(@source_project)
957 957
958 958 assert_equal @source_project.versions.size, @project.versions.size
959 959 @project.versions.each do |version|
960 960 assert version
961 961 assert_equal @project, version.project
962 962 end
963 963 end
964 964
965 965 should "copy wiki" do
966 966 assert_difference 'Wiki.count' do
967 967 assert @project.copy(@source_project)
968 968 end
969 969
970 970 assert @project.wiki
971 971 assert_not_equal @source_project.wiki, @project.wiki
972 972 assert_equal "Start page", @project.wiki.start_page
973 973 end
974 974
975 975 should "copy wiki pages and content with hierarchy" do
976 976 assert_difference 'WikiPage.count', @source_project.wiki.pages.size do
977 977 assert @project.copy(@source_project)
978 978 end
979 979
980 980 assert @project.wiki
981 981 assert_equal @source_project.wiki.pages.size, @project.wiki.pages.size
982 982
983 983 @project.wiki.pages.each do |wiki_page|
984 984 assert wiki_page.content
985 985 assert !@source_project.wiki.pages.include?(wiki_page)
986 986 end
987 987
988 988 parent = @project.wiki.find_page('Parent_page')
989 989 child1 = @project.wiki.find_page('Child_page_1')
990 990 child2 = @project.wiki.find_page('Child_page_2')
991 991 assert_equal parent, child1.parent
992 992 assert_equal parent, child2.parent
993 993 end
994 994
995 995 should "copy issue categories" do
996 996 assert @project.copy(@source_project)
997 997
998 998 assert_equal 2, @project.issue_categories.size
999 999 @project.issue_categories.each do |issue_category|
1000 1000 assert !@source_project.issue_categories.include?(issue_category)
1001 1001 end
1002 1002 end
1003 1003
1004 1004 should "copy boards" do
1005 1005 assert @project.copy(@source_project)
1006 1006
1007 1007 assert_equal 1, @project.boards.size
1008 1008 @project.boards.each do |board|
1009 1009 assert !@source_project.boards.include?(board)
1010 1010 end
1011 1011 end
1012 1012
1013 1013 should "change the new issues to use the copied issue categories" do
1014 1014 issue = Issue.find(4)
1015 1015 issue.update_attribute(:category_id, 3)
1016 1016
1017 1017 assert @project.copy(@source_project)
1018 1018
1019 1019 @project.issues.each do |issue|
1020 1020 assert issue.category
1021 1021 assert_equal "Stock management", issue.category.name # Same name
1022 1022 assert_not_equal IssueCategory.find(3), issue.category # Different record
1023 1023 end
1024 1024 end
1025 1025
1026 1026 should "limit copy with :only option" do
1027 1027 assert @project.members.empty?
1028 1028 assert @project.issue_categories.empty?
1029 1029 assert @source_project.issues.any?
1030 1030
1031 1031 assert @project.copy(@source_project, :only => ['members', 'issue_categories'])
1032 1032
1033 1033 assert @project.members.any?
1034 1034 assert @project.issue_categories.any?
1035 1035 assert @project.issues.empty?
1036 1036 end
1037 1037 end
1038 1038
1039 1039 def test_copy_should_copy_subtasks
1040 1040 source = Project.generate!(:tracker_ids => [1])
1041 1041 issue = Issue.generate_with_descendants!(:project => source)
1042 1042 project = Project.new(:name => 'Copy', :identifier => 'copy', :tracker_ids => [1])
1043 1043
1044 1044 assert_difference 'Project.count' do
1045 1045 assert_difference 'Issue.count', 1+issue.descendants.count do
1046 1046 assert project.copy(source.reload)
1047 1047 end
1048 1048 end
1049 1049 copy = Issue.where(:parent_id => nil).order("id DESC").first
1050 1050 assert_equal project, copy.project
1051 1051 assert_equal issue.descendants.count, copy.descendants.count
1052 1052 child_copy = copy.children.detect {|c| c.subject == 'Child1'}
1053 1053 assert child_copy.descendants.any?
1054 1054 end
1055 1055
1056 1056 context "#start_date" do
1057 1057 setup do
1058 1058 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
1059 1059 @project = Project.generate!(:identifier => 'test0')
1060 1060 @project.trackers << Tracker.generate!
1061 1061 end
1062 1062
1063 1063 should "be nil if there are no issues on the project" do
1064 1064 assert_nil @project.start_date
1065 1065 end
1066 1066
1067 1067 should "be tested when issues have no start date"
1068 1068
1069 1069 should "be the earliest start date of it's issues" do
1070 1070 early = 7.days.ago.to_date
1071 1071 Issue.generate!(:project => @project, :start_date => Date.today)
1072 1072 Issue.generate!(:project => @project, :start_date => early)
1073 1073
1074 1074 assert_equal early, @project.start_date
1075 1075 end
1076 1076
1077 1077 end
1078 1078
1079 1079 context "#due_date" do
1080 1080 setup do
1081 1081 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
1082 1082 @project = Project.generate!(:identifier => 'test0')
1083 1083 @project.trackers << Tracker.generate!
1084 1084 end
1085 1085
1086 1086 should "be nil if there are no issues on the project" do
1087 1087 assert_nil @project.due_date
1088 1088 end
1089 1089
1090 1090 should "be tested when issues have no due date"
1091 1091
1092 1092 should "be the latest due date of it's issues" do
1093 1093 future = 7.days.from_now.to_date
1094 1094 Issue.generate!(:project => @project, :due_date => future)
1095 1095 Issue.generate!(:project => @project, :due_date => Date.today)
1096 1096
1097 1097 assert_equal future, @project.due_date
1098 1098 end
1099 1099
1100 1100 should "be the latest due date of it's versions" do
1101 1101 future = 7.days.from_now.to_date
1102 1102 @project.versions << Version.generate!(:effective_date => future)
1103 1103 @project.versions << Version.generate!(:effective_date => Date.today)
1104 1104
1105 1105
1106 1106 assert_equal future, @project.due_date
1107 1107
1108 1108 end
1109 1109
1110 1110 should "pick the latest date from it's issues and versions" do
1111 1111 future = 7.days.from_now.to_date
1112 1112 far_future = 14.days.from_now.to_date
1113 1113 Issue.generate!(:project => @project, :due_date => far_future)
1114 1114 @project.versions << Version.generate!(:effective_date => future)
1115 1115
1116 1116 assert_equal far_future, @project.due_date
1117 1117 end
1118 1118
1119 1119 end
1120 1120
1121 1121 context "Project#completed_percent" do
1122 1122 setup do
1123 1123 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
1124 1124 @project = Project.generate!(:identifier => 'test0')
1125 1125 @project.trackers << Tracker.generate!
1126 1126 end
1127 1127
1128 1128 context "no versions" do
1129 1129 should "be 100" do
1130 1130 assert_equal 100, @project.completed_percent
1131 1131 end
1132 1132 end
1133 1133
1134 1134 context "with versions" do
1135 1135 should "return 0 if the versions have no issues" do
1136 1136 Version.generate!(:project => @project)
1137 1137 Version.generate!(:project => @project)
1138 1138
1139 1139 assert_equal 0, @project.completed_percent
1140 1140 end
1141 1141
1142 1142 should "return 100 if the version has only closed issues" do
1143 1143 v1 = Version.generate!(:project => @project)
1144 1144 Issue.generate!(:project => @project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v1)
1145 1145 v2 = Version.generate!(:project => @project)
1146 1146 Issue.generate!(:project => @project, :status => IssueStatus.find_by_name('Closed'), :fixed_version => v2)
1147 1147
1148 1148 assert_equal 100, @project.completed_percent
1149 1149 end
1150 1150
1151 1151 should "return the averaged completed percent of the versions (not weighted)" do
1152 1152 v1 = Version.generate!(:project => @project)
1153 1153 Issue.generate!(:project => @project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v1)
1154 1154 v2 = Version.generate!(:project => @project)
1155 1155 Issue.generate!(:project => @project, :status => IssueStatus.find_by_name('New'), :estimated_hours => 10, :done_ratio => 50, :fixed_version => v2)
1156 1156
1157 1157 assert_equal 50, @project.completed_percent
1158 1158 end
1159 1159
1160 1160 end
1161 1161 end
1162 1162
1163 1163 context "#notified_users" do
1164 1164 setup do
1165 1165 @project = Project.generate!
1166 1166 @role = Role.generate!
1167 1167
1168 1168 @user_with_membership_notification = User.generate!(:mail_notification => 'selected')
1169 1169 Member.create!(:project => @project, :roles => [@role], :principal => @user_with_membership_notification, :mail_notification => true)
1170 1170
1171 1171 @all_events_user = User.generate!(:mail_notification => 'all')
1172 1172 Member.create!(:project => @project, :roles => [@role], :principal => @all_events_user)
1173 1173
1174 1174 @no_events_user = User.generate!(:mail_notification => 'none')
1175 1175 Member.create!(:project => @project, :roles => [@role], :principal => @no_events_user)
1176 1176
1177 1177 @only_my_events_user = User.generate!(:mail_notification => 'only_my_events')
1178 1178 Member.create!(:project => @project, :roles => [@role], :principal => @only_my_events_user)
1179 1179
1180 1180 @only_assigned_user = User.generate!(:mail_notification => 'only_assigned')
1181 1181 Member.create!(:project => @project, :roles => [@role], :principal => @only_assigned_user)
1182 1182
1183 1183 @only_owned_user = User.generate!(:mail_notification => 'only_owner')
1184 1184 Member.create!(:project => @project, :roles => [@role], :principal => @only_owned_user)
1185 1185 end
1186 1186
1187 1187 should "include members with a mail notification" do
1188 1188 assert @project.notified_users.include?(@user_with_membership_notification)
1189 1189 end
1190 1190
1191 1191 should "include users with the 'all' notification option" do
1192 1192 assert @project.notified_users.include?(@all_events_user)
1193 1193 end
1194 1194
1195 1195 should "not include users with the 'none' notification option" do
1196 1196 assert !@project.notified_users.include?(@no_events_user)
1197 1197 end
1198 1198
1199 1199 should "not include users with the 'only_my_events' notification option" do
1200 1200 assert !@project.notified_users.include?(@only_my_events_user)
1201 1201 end
1202 1202
1203 1203 should "not include users with the 'only_assigned' notification option" do
1204 1204 assert !@project.notified_users.include?(@only_assigned_user)
1205 1205 end
1206 1206
1207 1207 should "not include users with the 'only_owner' notification option" do
1208 1208 assert !@project.notified_users.include?(@only_owned_user)
1209 1209 end
1210 1210 end
1211 1211
1212 1212 end
@@ -1,306 +1,306
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 RepositoryBazaarTest < ActiveSupport::TestCase
21 21 fixtures :projects
22 22
23 23 include Redmine::I18n
24 24
25 25 REPOSITORY_PATH = Rails.root.join('tmp/test/bazaar_repository').to_s
26 26 REPOSITORY_PATH_TRUNK = File.join(REPOSITORY_PATH, "trunk")
27 27 NUM_REV = 4
28 28
29 29 REPOSITORY_PATH_NON_ASCII = Rails.root.join(REPOSITORY_PATH + '/' + 'non_ascii').to_s
30 30
31 31 # Bazaar core does not support xml output such as Subversion and Mercurial.
32 32 # "bzr" command output and command line parameter depend on locale.
33 33 # So, non ASCII path tests cannot run independent locale.
34 34 #
35 35 # If you want to run Bazaar non ASCII path tests on Linux *Ruby 1.9*,
36 36 # you need to set locale character set "ISO-8859-1".
37 37 # E.g. "LANG=en_US.ISO-8859-1".
38 38 # On Linux other platforms (e.g. Ruby 1.8, JRuby),
39 39 # you need to set "RUN_LATIN1_OUTPUT_TEST = true" manually.
40 40 #
41 41 # On Windows, because it is too hard to change system locale,
42 42 # you cannot run Bazaar non ASCII path tests.
43 43 #
44 44 RUN_LATIN1_OUTPUT_TEST = (RUBY_PLATFORM != 'java' &&
45 45 REPOSITORY_PATH.respond_to?(:force_encoding) &&
46 46 Encoding.locale_charmap == "ISO-8859-1")
47 47
48 48 CHAR_1_UTF8_HEX = "\xc3\x9c"
49 49 CHAR_1_LATIN1_HEX = "\xdc"
50 50
51 51 def setup
52 52 @project = Project.find(3)
53 53 @repository = Repository::Bazaar.create(
54 54 :project => @project, :url => REPOSITORY_PATH_TRUNK,
55 55 :log_encoding => 'UTF-8')
56 56 assert @repository
57 57 @char_1_utf8 = CHAR_1_UTF8_HEX.dup
58 58 @char_1_ascii8bit = CHAR_1_LATIN1_HEX.dup
59 59 if @char_1_utf8.respond_to?(:force_encoding)
60 60 @char_1_utf8.force_encoding('UTF-8')
61 61 @char_1_ascii8bit.force_encoding('ASCII-8BIT')
62 62 end
63 63 end
64 64
65 65 def test_blank_path_to_repository_error_message
66 66 set_language_if_valid 'en'
67 67 repo = Repository::Bazaar.new(
68 68 :project => @project,
69 69 :identifier => 'test',
70 70 :log_encoding => 'UTF-8'
71 71 )
72 72 assert !repo.save
73 73 assert_include "Path to repository can't be blank",
74 74 repo.errors.full_messages
75 75 end
76 76
77 77 def test_blank_path_to_repository_error_message_fr
78 78 set_language_if_valid 'fr'
79 79 str = "Chemin du d\xc3\xa9p\xc3\xb4t doit \xc3\xaatre renseign\xc3\xa9(e)"
80 80 str.force_encoding('UTF-8') if str.respond_to?(:force_encoding)
81 81 repo = Repository::Bazaar.new(
82 82 :project => @project,
83 83 :url => "",
84 84 :identifier => 'test',
85 85 :log_encoding => 'UTF-8'
86 86 )
87 87 assert !repo.save
88 88 assert_include str, repo.errors.full_messages
89 89 end
90 90
91 91 if File.directory?(REPOSITORY_PATH_TRUNK)
92 92 def test_fetch_changesets_from_scratch
93 93 assert_equal 0, @repository.changesets.count
94 94 @repository.fetch_changesets
95 95 @project.reload
96 96
97 97 assert_equal NUM_REV, @repository.changesets.count
98 98 assert_equal 9, @repository.filechanges.count
99 99 assert_equal 'Initial import', @repository.changesets.find_by_revision('1').comments
100 100 end
101 101
102 102 def test_fetch_changesets_incremental
103 103 assert_equal 0, @repository.changesets.count
104 104 @repository.fetch_changesets
105 105 @project.reload
106 106 assert_equal NUM_REV, @repository.changesets.count
107 107 # Remove changesets with revision > 5
108 @repository.changesets.find(:all).each {|c| c.destroy if c.revision.to_i > 2}
108 @repository.changesets.all.each {|c| c.destroy if c.revision.to_i > 2}
109 109 @project.reload
110 110 assert_equal 2, @repository.changesets.count
111 111
112 112 @repository.fetch_changesets
113 113 @project.reload
114 114 assert_equal NUM_REV, @repository.changesets.count
115 115 end
116 116
117 117 def test_entries
118 118 entries = @repository.entries
119 119 assert_kind_of Redmine::Scm::Adapters::Entries, entries
120 120 assert_equal 2, entries.size
121 121
122 122 assert_equal 'dir', entries[0].kind
123 123 assert_equal 'directory', entries[0].name
124 124 assert_equal 'directory', entries[0].path
125 125
126 126 assert_equal 'file', entries[1].kind
127 127 assert_equal 'doc-mkdir.txt', entries[1].name
128 128 assert_equal 'doc-mkdir.txt', entries[1].path
129 129 end
130 130
131 131 def test_entries_in_subdirectory
132 132 entries = @repository.entries('directory')
133 133 assert_equal 3, entries.size
134 134
135 135 assert_equal 'file', entries.last.kind
136 136 assert_equal 'edit.png', entries.last.name
137 137 assert_equal 'directory/edit.png', entries.last.path
138 138 end
139 139
140 140 def test_previous
141 141 assert_equal 0, @repository.changesets.count
142 142 @repository.fetch_changesets
143 143 @project.reload
144 144 assert_equal NUM_REV, @repository.changesets.count
145 145 changeset = @repository.find_changeset_by_name('3')
146 146 assert_equal @repository.find_changeset_by_name('2'), changeset.previous
147 147 end
148 148
149 149 def test_previous_nil
150 150 assert_equal 0, @repository.changesets.count
151 151 @repository.fetch_changesets
152 152 @project.reload
153 153 assert_equal NUM_REV, @repository.changesets.count
154 154 changeset = @repository.find_changeset_by_name('1')
155 155 assert_nil changeset.previous
156 156 end
157 157
158 158 def test_next
159 159 assert_equal 0, @repository.changesets.count
160 160 @repository.fetch_changesets
161 161 @project.reload
162 162 assert_equal NUM_REV, @repository.changesets.count
163 163 changeset = @repository.find_changeset_by_name('2')
164 164 assert_equal @repository.find_changeset_by_name('3'), changeset.next
165 165 end
166 166
167 167 def test_next_nil
168 168 assert_equal 0, @repository.changesets.count
169 169 @repository.fetch_changesets
170 170 @project.reload
171 171 assert_equal NUM_REV, @repository.changesets.count
172 172 changeset = @repository.find_changeset_by_name('4')
173 173 assert_nil changeset.next
174 174 end
175 175
176 176 if File.directory?(REPOSITORY_PATH_NON_ASCII) && RUN_LATIN1_OUTPUT_TEST
177 177 def test_cat_latin1_path
178 178 latin1_repo = create_latin1_repo
179 179 buf = latin1_repo.cat(
180 180 "test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-2.txt", 2)
181 181 assert buf
182 182 lines = buf.split("\n")
183 183 assert_equal 2, lines.length
184 184 assert_equal 'It is written in Python.', lines[1]
185 185
186 186 buf = latin1_repo.cat(
187 187 "test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt", 2)
188 188 assert buf
189 189 lines = buf.split("\n")
190 190 assert_equal 1, lines.length
191 191 assert_equal "test-#{@char_1_ascii8bit}.txt", lines[0]
192 192 end
193 193
194 194 def test_annotate_latin1_path
195 195 latin1_repo = create_latin1_repo
196 196 ann1 = latin1_repo.annotate(
197 197 "test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-2.txt", 2)
198 198 assert_equal 2, ann1.lines.size
199 199 assert_equal '2', ann1.revisions[0].identifier
200 200 assert_equal 'test00@', ann1.revisions[0].author
201 201 assert_equal 'It is written in Python.', ann1.lines[1]
202 202 ann2 = latin1_repo.annotate(
203 203 "test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt", 2)
204 204 assert_equal 1, ann2.lines.size
205 205 assert_equal '2', ann2.revisions[0].identifier
206 206 assert_equal 'test00@', ann2.revisions[0].author
207 207 assert_equal "test-#{@char_1_ascii8bit}.txt", ann2.lines[0]
208 208 end
209 209
210 210 def test_diff_latin1_path
211 211 latin1_repo = create_latin1_repo
212 212 diff1 = latin1_repo.diff(
213 213 "test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt", 2, 1)
214 214 assert_equal 7, diff1.size
215 215 buf = diff1[5].gsub(/\r\n|\r|\n/, "")
216 216 assert_equal "+test-#{@char_1_ascii8bit}.txt", buf
217 217 end
218 218
219 219 def test_entries_latin1_path
220 220 latin1_repo = create_latin1_repo
221 221 entries = latin1_repo.entries("test-#{@char_1_utf8}-dir", 2)
222 222 assert_kind_of Redmine::Scm::Adapters::Entries, entries
223 223 assert_equal 3, entries.size
224 224 assert_equal 'file', entries[1].kind
225 225 assert_equal "test-#{@char_1_utf8}-1.txt", entries[0].name
226 226 assert_equal "test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt", entries[0].path
227 227 end
228 228
229 229 def test_entry_latin1_path
230 230 latin1_repo = create_latin1_repo
231 231 ["test-#{@char_1_utf8}-dir",
232 232 "/test-#{@char_1_utf8}-dir",
233 233 "/test-#{@char_1_utf8}-dir/"
234 234 ].each do |path|
235 235 entry = latin1_repo.entry(path, 2)
236 236 assert_equal "test-#{@char_1_utf8}-dir", entry.path
237 237 assert_equal "dir", entry.kind
238 238 end
239 239 ["test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt",
240 240 "/test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt"
241 241 ].each do |path|
242 242 entry = latin1_repo.entry(path, 2)
243 243 assert_equal "test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt",
244 244 entry.path
245 245 assert_equal "file", entry.kind
246 246 end
247 247 end
248 248
249 249 def test_changeset_latin1_path
250 250 latin1_repo = create_latin1_repo
251 251 assert_equal 0, latin1_repo.changesets.count
252 252 latin1_repo.fetch_changesets
253 253 @project.reload
254 254 assert_equal 3, latin1_repo.changesets.count
255 255
256 256 cs2 = latin1_repo.changesets.find_by_revision('2')
257 257 assert_not_nil cs2
258 258 assert_equal "test-#{@char_1_utf8}", cs2.comments
259 259 c2 = cs2.filechanges.sort_by(&:path)
260 260 assert_equal 4, c2.size
261 261 assert_equal 'A', c2[0].action
262 262 assert_equal "/test-#{@char_1_utf8}-dir/", c2[0].path
263 263 assert_equal 'A', c2[1].action
264 264 assert_equal "/test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-1.txt", c2[1].path
265 265 assert_equal 'A', c2[2].action
266 266 assert_equal "/test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-2.txt", c2[2].path
267 267 assert_equal 'A', c2[3].action
268 268 assert_equal "/test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}.txt", c2[3].path
269 269
270 270 cs3 = latin1_repo.changesets.find_by_revision('3')
271 271 assert_not_nil cs3
272 272 assert_equal "modify, move and delete #{@char_1_utf8} files", cs3.comments
273 273 c3 = cs3.filechanges.sort_by(&:path)
274 274 assert_equal 3, c3.size
275 275 assert_equal 'M', c3[0].action
276 276 assert_equal "/test-#{@char_1_utf8}-1.txt", c3[0].path
277 277 assert_equal 'D', c3[1].action
278 278 assert_equal "/test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}-2.txt", c3[1].path
279 279 assert_equal 'M', c3[2].action
280 280 assert_equal "/test-#{@char_1_utf8}-dir/test-#{@char_1_utf8}.txt", c3[2].path
281 281 end
282 282 else
283 283 msg = "Bazaar non ASCII output test cannot run this environment." + "\n"
284 284 if msg.respond_to?(:force_encoding)
285 285 msg += "Encoding.locale_charmap: " + Encoding.locale_charmap + "\n"
286 286 end
287 287 puts msg
288 288 end
289 289
290 290 private
291 291
292 292 def create_latin1_repo
293 293 repo = Repository::Bazaar.create(
294 294 :project => @project,
295 295 :identifier => 'latin1',
296 296 :url => REPOSITORY_PATH_NON_ASCII,
297 297 :log_encoding => 'ISO-8859-1'
298 298 )
299 299 assert repo
300 300 repo
301 301 end
302 302 else
303 303 puts "Bazaar test repository NOT FOUND. Skipping unit tests !!!"
304 304 def test_fake; assert true end
305 305 end
306 306 end
@@ -1,241 +1,241
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 require 'pp'
20 20 class RepositoryCvsTest < ActiveSupport::TestCase
21 21 fixtures :projects
22 22
23 23 include Redmine::I18n
24 24
25 25 REPOSITORY_PATH = Rails.root.join('tmp/test/cvs_repository').to_s
26 26 REPOSITORY_PATH.gsub!(/\//, "\\") if Redmine::Platform.mswin?
27 27 # CVS module
28 28 MODULE_NAME = 'test'
29 29 CHANGESETS_NUM = 7
30 30
31 31 def setup
32 32 @project = Project.find(3)
33 33 @repository = Repository::Cvs.create(:project => @project,
34 34 :root_url => REPOSITORY_PATH,
35 35 :url => MODULE_NAME,
36 36 :log_encoding => 'UTF-8')
37 37 assert @repository
38 38 end
39 39
40 40 def test_blank_module_error_message
41 41 set_language_if_valid 'en'
42 42 repo = Repository::Cvs.new(
43 43 :project => @project,
44 44 :identifier => 'test',
45 45 :log_encoding => 'UTF-8',
46 46 :root_url => REPOSITORY_PATH
47 47 )
48 48 assert !repo.save
49 49 assert_include "Module can't be blank",
50 50 repo.errors.full_messages
51 51 end
52 52
53 53 def test_blank_module_error_message_fr
54 54 set_language_if_valid 'fr'
55 55 str = "Module doit \xc3\xaatre renseign\xc3\xa9(e)"
56 56 str.force_encoding('UTF-8') if str.respond_to?(:force_encoding)
57 57 repo = Repository::Cvs.new(
58 58 :project => @project,
59 59 :identifier => 'test',
60 60 :log_encoding => 'UTF-8',
61 61 :path_encoding => '',
62 62 :url => '',
63 63 :root_url => REPOSITORY_PATH
64 64 )
65 65 assert !repo.save
66 66 assert_include str, repo.errors.full_messages
67 67 end
68 68
69 69 def test_blank_cvsroot_error_message
70 70 set_language_if_valid 'en'
71 71 repo = Repository::Cvs.new(
72 72 :project => @project,
73 73 :identifier => 'test',
74 74 :log_encoding => 'UTF-8',
75 75 :url => MODULE_NAME
76 76 )
77 77 assert !repo.save
78 78 assert_include "CVSROOT can't be blank",
79 79 repo.errors.full_messages
80 80 end
81 81
82 82 def test_blank_cvsroot_error_message_fr
83 83 set_language_if_valid 'fr'
84 84 str = "CVSROOT doit \xc3\xaatre renseign\xc3\xa9(e)"
85 85 str.force_encoding('UTF-8') if str.respond_to?(:force_encoding)
86 86 repo = Repository::Cvs.new(
87 87 :project => @project,
88 88 :identifier => 'test',
89 89 :log_encoding => 'UTF-8',
90 90 :path_encoding => '',
91 91 :url => MODULE_NAME,
92 92 :root_url => ''
93 93 )
94 94 assert !repo.save
95 95 assert_include str, repo.errors.full_messages
96 96 end
97 97
98 98 if File.directory?(REPOSITORY_PATH)
99 99 def test_fetch_changesets_from_scratch
100 100 assert_equal 0, @repository.changesets.count
101 101 @repository.fetch_changesets
102 102 @project.reload
103 103
104 104 assert_equal CHANGESETS_NUM, @repository.changesets.count
105 105 assert_equal 16, @repository.filechanges.count
106 106 assert_not_nil @repository.changesets.find_by_comments('Two files changed')
107 107
108 108 r2 = @repository.changesets.find_by_revision('2')
109 109 assert_equal 'v1-20071213-162510', r2.scmid
110 110 end
111 111
112 112 def test_fetch_changesets_incremental
113 113 assert_equal 0, @repository.changesets.count
114 114 @repository.fetch_changesets
115 115 @project.reload
116 116 assert_equal CHANGESETS_NUM, @repository.changesets.count
117 117
118 118 # Remove changesets with revision > 3
119 @repository.changesets.find(:all).each {|c| c.destroy if c.revision.to_i > 3}
119 @repository.changesets.all.each {|c| c.destroy if c.revision.to_i > 3}
120 120 @project.reload
121 121 assert_equal 3, @repository.changesets.count
122 122 assert_equal %w|3 2 1|, @repository.changesets.all.collect(&:revision)
123 123
124 124 rev3_commit = @repository.changesets.find(:first, :order => 'committed_on DESC')
125 125 assert_equal '3', rev3_commit.revision
126 126 # 2007-12-14 01:27:22 +0900
127 127 rev3_committed_on = Time.gm(2007, 12, 13, 16, 27, 22)
128 128 assert_equal 'HEAD-20071213-162722', rev3_commit.scmid
129 129 assert_equal rev3_committed_on, rev3_commit.committed_on
130 130 latest_rev = @repository.latest_changeset
131 131 assert_equal rev3_committed_on, latest_rev.committed_on
132 132
133 133 @repository.fetch_changesets
134 134 @project.reload
135 135 assert_equal CHANGESETS_NUM, @repository.changesets.count
136 136 assert_equal %w|7 6 5 4 3 2 1|, @repository.changesets.all.collect(&:revision)
137 137 rev5_commit = @repository.changesets.find_by_revision('5')
138 138 assert_equal 'HEAD-20071213-163001', rev5_commit.scmid
139 139 # 2007-12-14 01:30:01 +0900
140 140 rev5_committed_on = Time.gm(2007, 12, 13, 16, 30, 1)
141 141 assert_equal rev5_committed_on, rev5_commit.committed_on
142 142 end
143 143
144 144 def test_deleted_files_should_not_be_listed
145 145 assert_equal 0, @repository.changesets.count
146 146 @repository.fetch_changesets
147 147 @project.reload
148 148 assert_equal CHANGESETS_NUM, @repository.changesets.count
149 149
150 150 entries = @repository.entries('sources')
151 151 assert entries.detect {|e| e.name == 'watchers_controller.rb'}
152 152 assert_nil entries.detect {|e| e.name == 'welcome_controller.rb'}
153 153 end
154 154
155 155 def test_entries_rev3
156 156 assert_equal 0, @repository.changesets.count
157 157 @repository.fetch_changesets
158 158 @project.reload
159 159 assert_equal CHANGESETS_NUM, @repository.changesets.count
160 160 entries = @repository.entries('', '3')
161 161 assert_kind_of Redmine::Scm::Adapters::Entries, entries
162 162 assert_equal 3, entries.size
163 163 assert_equal entries[2].name, "README"
164 164 assert_equal entries[2].lastrev.time, Time.gm(2007, 12, 13, 16, 27, 22)
165 165 assert_equal entries[2].lastrev.identifier, '3'
166 166 assert_equal entries[2].lastrev.revision, '3'
167 167 assert_equal entries[2].lastrev.author, 'LANG'
168 168 end
169 169
170 170 def test_entries_invalid_path
171 171 assert_equal 0, @repository.changesets.count
172 172 @repository.fetch_changesets
173 173 @project.reload
174 174 assert_equal CHANGESETS_NUM, @repository.changesets.count
175 175 assert_nil @repository.entries('missing')
176 176 assert_nil @repository.entries('missing', '3')
177 177 end
178 178
179 179 def test_entries_invalid_revision
180 180 assert_equal 0, @repository.changesets.count
181 181 @repository.fetch_changesets
182 182 @project.reload
183 183 assert_equal CHANGESETS_NUM, @repository.changesets.count
184 184 assert_nil @repository.entries('', '123')
185 185 end
186 186
187 187 def test_cat
188 188 assert_equal 0, @repository.changesets.count
189 189 @repository.fetch_changesets
190 190 @project.reload
191 191 assert_equal CHANGESETS_NUM, @repository.changesets.count
192 192 buf = @repository.cat('README')
193 193 assert buf
194 194 lines = buf.split("\n")
195 195 assert_equal 3, lines.length
196 196 buf = lines[1].gsub(/\r$/, "")
197 197 assert_equal 'with one change', buf
198 198 buf = @repository.cat('README', '1')
199 199 assert buf
200 200 lines = buf.split("\n")
201 201 assert_equal 1, lines.length
202 202 buf = lines[0].gsub(/\r$/, "")
203 203 assert_equal 'CVS test repository', buf
204 204 assert_nil @repository.cat('missing.rb')
205 205
206 206 # sources/welcome_controller.rb is removed at revision 5.
207 207 assert @repository.cat('sources/welcome_controller.rb', '4')
208 208 assert @repository.cat('sources/welcome_controller.rb', '5').blank?
209 209
210 210 # invalid revision
211 211 assert @repository.cat('README', '123').blank?
212 212 end
213 213
214 214 def test_annotate
215 215 assert_equal 0, @repository.changesets.count
216 216 @repository.fetch_changesets
217 217 @project.reload
218 218 assert_equal CHANGESETS_NUM, @repository.changesets.count
219 219 ann = @repository.annotate('README')
220 220 assert ann
221 221 assert_equal 3, ann.revisions.length
222 222 assert_equal '1.2', ann.revisions[1].revision
223 223 assert_equal 'LANG', ann.revisions[1].author
224 224 assert_equal 'with one change', ann.lines[1]
225 225
226 226 ann = @repository.annotate('README', '1')
227 227 assert ann
228 228 assert_equal 1, ann.revisions.length
229 229 assert_equal '1.1', ann.revisions[0].revision
230 230 assert_equal 'LANG', ann.revisions[0].author
231 231 assert_equal 'CVS test repository', ann.lines[0]
232 232
233 233 # invalid revision
234 234 assert_nil @repository.annotate('README', '123')
235 235 end
236 236
237 237 else
238 238 puts "CVS test repository NOT FOUND. Skipping unit tests !!!"
239 239 def test_fake; assert true end
240 240 end
241 241 end
@@ -1,129 +1,129
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 RepositoryDarcsTest < ActiveSupport::TestCase
21 21 fixtures :projects
22 22
23 23 include Redmine::I18n
24 24
25 25 REPOSITORY_PATH = Rails.root.join('tmp/test/darcs_repository').to_s
26 26 NUM_REV = 6
27 27
28 28 def setup
29 29 @project = Project.find(3)
30 30 @repository = Repository::Darcs.create(
31 31 :project => @project,
32 32 :url => REPOSITORY_PATH,
33 33 :log_encoding => 'UTF-8'
34 34 )
35 35 assert @repository
36 36 end
37 37
38 38 def test_blank_path_to_repository_error_message
39 39 set_language_if_valid 'en'
40 40 repo = Repository::Darcs.new(
41 41 :project => @project,
42 42 :identifier => 'test',
43 43 :log_encoding => 'UTF-8'
44 44 )
45 45 assert !repo.save
46 46 assert_include "Path to repository can't be blank",
47 47 repo.errors.full_messages
48 48 end
49 49
50 50 def test_blank_path_to_repository_error_message_fr
51 51 set_language_if_valid 'fr'
52 52 str = "Chemin du d\xc3\xa9p\xc3\xb4t doit \xc3\xaatre renseign\xc3\xa9(e)"
53 53 str.force_encoding('UTF-8') if str.respond_to?(:force_encoding)
54 54 repo = Repository::Darcs.new(
55 55 :project => @project,
56 56 :url => "",
57 57 :identifier => 'test',
58 58 :log_encoding => 'UTF-8'
59 59 )
60 60 assert !repo.save
61 61 assert_include str, repo.errors.full_messages
62 62 end
63 63
64 64 if File.directory?(REPOSITORY_PATH)
65 65 def test_fetch_changesets_from_scratch
66 66 assert_equal 0, @repository.changesets.count
67 67 @repository.fetch_changesets
68 68 @project.reload
69 69
70 70 assert_equal NUM_REV, @repository.changesets.count
71 71 assert_equal 13, @repository.filechanges.count
72 72 assert_equal "Initial commit.", @repository.changesets.find_by_revision('1').comments
73 73 end
74 74
75 75 def test_fetch_changesets_incremental
76 76 assert_equal 0, @repository.changesets.count
77 77 @repository.fetch_changesets
78 78 @project.reload
79 79 assert_equal NUM_REV, @repository.changesets.count
80 80
81 81 # Remove changesets with revision > 3
82 @repository.changesets.find(:all).each {|c| c.destroy if c.revision.to_i > 3}
82 @repository.changesets.all.each {|c| c.destroy if c.revision.to_i > 3}
83 83 @project.reload
84 84 assert_equal 3, @repository.changesets.count
85 85
86 86 @repository.fetch_changesets
87 87 @project.reload
88 88 assert_equal NUM_REV, @repository.changesets.count
89 89 end
90 90
91 91 def test_entries
92 92 entries = @repository.entries
93 93 assert_kind_of Redmine::Scm::Adapters::Entries, entries
94 94 end
95 95
96 96 def test_entries_invalid_revision
97 97 assert_equal 0, @repository.changesets.count
98 98 @repository.fetch_changesets
99 99 @project.reload
100 100 assert_equal NUM_REV, @repository.changesets.count
101 101 assert_nil @repository.entries('', '123')
102 102 end
103 103
104 104 def test_deleted_files_should_not_be_listed
105 105 assert_equal 0, @repository.changesets.count
106 106 @repository.fetch_changesets
107 107 @project.reload
108 108 assert_equal NUM_REV, @repository.changesets.count
109 109 entries = @repository.entries('sources')
110 110 assert entries.detect {|e| e.name == 'watchers_controller.rb'}
111 111 assert_nil entries.detect {|e| e.name == 'welcome_controller.rb'}
112 112 end
113 113
114 114 def test_cat
115 115 if @repository.scm.supports_cat?
116 116 assert_equal 0, @repository.changesets.count
117 117 @repository.fetch_changesets
118 118 @project.reload
119 119 assert_equal NUM_REV, @repository.changesets.count
120 120 cat = @repository.cat("sources/welcome_controller.rb", 2)
121 121 assert_not_nil cat
122 122 assert cat.include?('class WelcomeController < ApplicationController')
123 123 end
124 124 end
125 125 else
126 126 puts "Darcs test repository NOT FOUND. Skipping unit tests !!!"
127 127 def test_fake; assert true end
128 128 end
129 129 end
@@ -1,377 +1,377
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 RepositoryMercurialTest < ActiveSupport::TestCase
21 21 fixtures :projects
22 22
23 23 include Redmine::I18n
24 24
25 25 REPOSITORY_PATH = Rails.root.join('tmp/test/mercurial_repository').to_s
26 26 NUM_REV = 32
27 27 CHAR_1_HEX = "\xc3\x9c"
28 28
29 29 def setup
30 30 @project = Project.find(3)
31 31 @repository = Repository::Mercurial.create(
32 32 :project => @project,
33 33 :url => REPOSITORY_PATH,
34 34 :path_encoding => 'ISO-8859-1'
35 35 )
36 36 assert @repository
37 37 @char_1 = CHAR_1_HEX.dup
38 38 @tag_char_1 = "tag-#{CHAR_1_HEX}-00"
39 39 @branch_char_0 = "branch-#{CHAR_1_HEX}-00"
40 40 @branch_char_1 = "branch-#{CHAR_1_HEX}-01"
41 41 if @char_1.respond_to?(:force_encoding)
42 42 @char_1.force_encoding('UTF-8')
43 43 @tag_char_1.force_encoding('UTF-8')
44 44 @branch_char_0.force_encoding('UTF-8')
45 45 @branch_char_1.force_encoding('UTF-8')
46 46 end
47 47 end
48 48
49 49
50 50 def test_blank_path_to_repository_error_message
51 51 set_language_if_valid 'en'
52 52 repo = Repository::Mercurial.new(
53 53 :project => @project,
54 54 :identifier => 'test'
55 55 )
56 56 assert !repo.save
57 57 assert_include "Path to repository can't be blank",
58 58 repo.errors.full_messages
59 59 end
60 60
61 61 def test_blank_path_to_repository_error_message_fr
62 62 set_language_if_valid 'fr'
63 63 str = "Chemin du d\xc3\xa9p\xc3\xb4t doit \xc3\xaatre renseign\xc3\xa9(e)"
64 64 str.force_encoding('UTF-8') if str.respond_to?(:force_encoding)
65 65 repo = Repository::Mercurial.new(
66 66 :project => @project,
67 67 :url => "",
68 68 :identifier => 'test',
69 69 :path_encoding => ''
70 70 )
71 71 assert !repo.save
72 72 assert_include str, repo.errors.full_messages
73 73 end
74 74
75 75 if File.directory?(REPOSITORY_PATH)
76 76 def test_scm_available
77 77 klass = Repository::Mercurial
78 78 assert_equal "Mercurial", klass.scm_name
79 79 assert klass.scm_adapter_class
80 80 assert_not_equal "", klass.scm_command
81 81 assert_equal true, klass.scm_available
82 82 end
83 83
84 84 def test_entries
85 85 entries = @repository.entries
86 86 assert_kind_of Redmine::Scm::Adapters::Entries, entries
87 87 end
88 88
89 89 def test_fetch_changesets_from_scratch
90 90 assert_equal 0, @repository.changesets.count
91 91 @repository.fetch_changesets
92 92 @project.reload
93 93 assert_equal NUM_REV, @repository.changesets.count
94 94 assert_equal 46, @repository.filechanges.count
95 95 assert_equal "Initial import.\nThe repository contains 3 files.",
96 96 @repository.changesets.find_by_revision('0').comments
97 97 end
98 98
99 99 def test_fetch_changesets_incremental
100 100 assert_equal 0, @repository.changesets.count
101 101 @repository.fetch_changesets
102 102 @project.reload
103 103 assert_equal NUM_REV, @repository.changesets.count
104 104 # Remove changesets with revision > 2
105 @repository.changesets.find(:all).each {|c| c.destroy if c.revision.to_i > 2}
105 @repository.changesets.all.each {|c| c.destroy if c.revision.to_i > 2}
106 106 @project.reload
107 107 assert_equal 3, @repository.changesets.count
108 108
109 109 @repository.fetch_changesets
110 110 @project.reload
111 111 assert_equal NUM_REV, @repository.changesets.count
112 112 end
113 113
114 114 def test_isodatesec
115 115 # Template keyword 'isodatesec' supported in Mercurial 1.0 and higher
116 116 if @repository.scm.class.client_version_above?([1, 0])
117 117 assert_equal 0, @repository.changesets.count
118 118 @repository.fetch_changesets
119 119 @project.reload
120 120 assert_equal NUM_REV, @repository.changesets.count
121 121 rev0_committed_on = Time.gm(2007, 12, 14, 9, 22, 52)
122 122 assert_equal @repository.changesets.find_by_revision('0').committed_on, rev0_committed_on
123 123 end
124 124 end
125 125
126 126 def test_changeset_order_by_revision
127 127 assert_equal 0, @repository.changesets.count
128 128 @repository.fetch_changesets
129 129 @project.reload
130 130 assert_equal NUM_REV, @repository.changesets.count
131 131
132 132 c0 = @repository.latest_changeset
133 133 c1 = @repository.changesets.find_by_revision('0')
134 134 # sorted by revision (id), not by date
135 135 assert c0.revision.to_i > c1.revision.to_i
136 136 assert c0.committed_on < c1.committed_on
137 137 end
138 138
139 139 def test_latest_changesets
140 140 assert_equal 0, @repository.changesets.count
141 141 @repository.fetch_changesets
142 142 @project.reload
143 143 assert_equal NUM_REV, @repository.changesets.count
144 144
145 145 # with_limit
146 146 changesets = @repository.latest_changesets('', nil, 2)
147 147 assert_equal %w|31 30|, changesets.collect(&:revision)
148 148
149 149 # with_filepath
150 150 changesets = @repository.latest_changesets(
151 151 '/sql_escape/percent%dir/percent%file1.txt', nil)
152 152 assert_equal %w|30 11 10 9|, changesets.collect(&:revision)
153 153
154 154 changesets = @repository.latest_changesets(
155 155 '/sql_escape/underscore_dir/understrike_file.txt', nil)
156 156 assert_equal %w|30 12 9|, changesets.collect(&:revision)
157 157
158 158 changesets = @repository.latest_changesets('README', nil)
159 159 assert_equal %w|31 30 28 17 8 6 1 0|, changesets.collect(&:revision)
160 160
161 161 changesets = @repository.latest_changesets('README','8')
162 162 assert_equal %w|8 6 1 0|, changesets.collect(&:revision)
163 163
164 164 changesets = @repository.latest_changesets('README','8', 2)
165 165 assert_equal %w|8 6|, changesets.collect(&:revision)
166 166
167 167 # with_dirpath
168 168 changesets = @repository.latest_changesets('images', nil)
169 169 assert_equal %w|1 0|, changesets.collect(&:revision)
170 170
171 171 path = 'sql_escape/percent%dir'
172 172 changesets = @repository.latest_changesets(path, nil)
173 173 assert_equal %w|30 13 11 10 9|, changesets.collect(&:revision)
174 174
175 175 changesets = @repository.latest_changesets(path, '11')
176 176 assert_equal %w|11 10 9|, changesets.collect(&:revision)
177 177
178 178 changesets = @repository.latest_changesets(path, '11', 2)
179 179 assert_equal %w|11 10|, changesets.collect(&:revision)
180 180
181 181 path = 'sql_escape/underscore_dir'
182 182 changesets = @repository.latest_changesets(path, nil)
183 183 assert_equal %w|30 13 12 9|, changesets.collect(&:revision)
184 184
185 185 changesets = @repository.latest_changesets(path, '12')
186 186 assert_equal %w|12 9|, changesets.collect(&:revision)
187 187
188 188 changesets = @repository.latest_changesets(path, '12', 1)
189 189 assert_equal %w|12|, changesets.collect(&:revision)
190 190
191 191 # tag
192 192 changesets = @repository.latest_changesets('', 'tag_test.00')
193 193 assert_equal %w|5 4 3 2 1 0|, changesets.collect(&:revision)
194 194
195 195 changesets = @repository.latest_changesets('', 'tag_test.00', 2)
196 196 assert_equal %w|5 4|, changesets.collect(&:revision)
197 197
198 198 changesets = @repository.latest_changesets('sources', 'tag_test.00')
199 199 assert_equal %w|4 3 2 1 0|, changesets.collect(&:revision)
200 200
201 201 changesets = @repository.latest_changesets('sources', 'tag_test.00', 2)
202 202 assert_equal %w|4 3|, changesets.collect(&:revision)
203 203
204 204 # named branch
205 205 if @repository.scm.class.client_version_above?([1, 6])
206 206 changesets = @repository.latest_changesets('', @branch_char_1)
207 207 assert_equal %w|27 26|, changesets.collect(&:revision)
208 208 end
209 209
210 210 changesets = @repository.latest_changesets("latin-1-dir/test-#{@char_1}-subdir", @branch_char_1)
211 211 assert_equal %w|27|, changesets.collect(&:revision)
212 212 end
213 213
214 214 def test_copied_files
215 215 assert_equal 0, @repository.changesets.count
216 216 @repository.fetch_changesets
217 217 @project.reload
218 218 assert_equal NUM_REV, @repository.changesets.count
219 219
220 220 cs1 = @repository.changesets.find_by_revision('13')
221 221 assert_not_nil cs1
222 222 c1 = cs1.filechanges.sort_by(&:path)
223 223 assert_equal 2, c1.size
224 224
225 225 assert_equal 'A', c1[0].action
226 226 assert_equal '/sql_escape/percent%dir/percentfile1.txt', c1[0].path
227 227 assert_equal '/sql_escape/percent%dir/percent%file1.txt', c1[0].from_path
228 228 assert_equal '3a330eb32958', c1[0].from_revision
229 229
230 230 assert_equal 'A', c1[1].action
231 231 assert_equal '/sql_escape/underscore_dir/understrike-file.txt', c1[1].path
232 232 assert_equal '/sql_escape/underscore_dir/understrike_file.txt', c1[1].from_path
233 233
234 234 cs2 = @repository.changesets.find_by_revision('15')
235 235 c2 = cs2.filechanges
236 236 assert_equal 1, c2.size
237 237
238 238 assert_equal 'A', c2[0].action
239 239 assert_equal '/README (1)[2]&,%.-3_4', c2[0].path
240 240 assert_equal '/README', c2[0].from_path
241 241 assert_equal '933ca60293d7', c2[0].from_revision
242 242
243 243 cs3 = @repository.changesets.find_by_revision('19')
244 244 c3 = cs3.filechanges
245 245 assert_equal 1, c3.size
246 246 assert_equal 'A', c3[0].action
247 247 assert_equal "/latin-1-dir/test-#{@char_1}-1.txt", c3[0].path
248 248 assert_equal "/latin-1-dir/test-#{@char_1}.txt", c3[0].from_path
249 249 assert_equal '5d9891a1b425', c3[0].from_revision
250 250 end
251 251
252 252 def test_find_changeset_by_name
253 253 assert_equal 0, @repository.changesets.count
254 254 @repository.fetch_changesets
255 255 @project.reload
256 256 assert_equal NUM_REV, @repository.changesets.count
257 257 %w|2 400bb8672109 400|.each do |r|
258 258 assert_equal '2', @repository.find_changeset_by_name(r).revision
259 259 end
260 260 end
261 261
262 262 def test_find_changeset_by_invalid_name
263 263 assert_equal 0, @repository.changesets.count
264 264 @repository.fetch_changesets
265 265 @project.reload
266 266 assert_equal NUM_REV, @repository.changesets.count
267 267 assert_nil @repository.find_changeset_by_name('100000')
268 268 end
269 269
270 270 def test_identifier
271 271 assert_equal 0, @repository.changesets.count
272 272 @repository.fetch_changesets
273 273 @project.reload
274 274 assert_equal NUM_REV, @repository.changesets.count
275 275 c = @repository.changesets.find_by_revision('2')
276 276 assert_equal c.scmid, c.identifier
277 277 end
278 278
279 279 def test_format_identifier
280 280 assert_equal 0, @repository.changesets.count
281 281 @repository.fetch_changesets
282 282 @project.reload
283 283 assert_equal NUM_REV, @repository.changesets.count
284 284 c = @repository.changesets.find_by_revision('2')
285 285 assert_equal '2:400bb8672109', c.format_identifier
286 286 end
287 287
288 288 def test_find_changeset_by_empty_name
289 289 assert_equal 0, @repository.changesets.count
290 290 @repository.fetch_changesets
291 291 @project.reload
292 292 assert_equal NUM_REV, @repository.changesets.count
293 293 ['', ' ', nil].each do |r|
294 294 assert_nil @repository.find_changeset_by_name(r)
295 295 end
296 296 end
297 297
298 298 def test_parents
299 299 assert_equal 0, @repository.changesets.count
300 300 @repository.fetch_changesets
301 301 @project.reload
302 302 assert_equal NUM_REV, @repository.changesets.count
303 303 r1 = @repository.changesets.find_by_revision('0')
304 304 assert_equal [], r1.parents
305 305 r2 = @repository.changesets.find_by_revision('1')
306 306 assert_equal 1, r2.parents.length
307 307 assert_equal "0885933ad4f6",
308 308 r2.parents[0].identifier
309 309 r3 = @repository.changesets.find_by_revision('30')
310 310 assert_equal 2, r3.parents.length
311 311 r4 = [r3.parents[0].identifier, r3.parents[1].identifier].sort
312 312 assert_equal "3a330eb32958", r4[0]
313 313 assert_equal "a94b0528f24f", r4[1]
314 314 end
315 315
316 316 def test_activities
317 317 c = Changeset.new(:repository => @repository,
318 318 :committed_on => Time.now,
319 319 :revision => '123',
320 320 :scmid => 'abc400bb8672',
321 321 :comments => 'test')
322 322 assert c.event_title.include?('123:abc400bb8672:')
323 323 assert_equal 'abc400bb8672', c.event_url[:rev]
324 324 end
325 325
326 326 def test_previous
327 327 assert_equal 0, @repository.changesets.count
328 328 @repository.fetch_changesets
329 329 @project.reload
330 330 assert_equal NUM_REV, @repository.changesets.count
331 331 %w|28 3ae45e2d177d 3ae45|.each do |r1|
332 332 changeset = @repository.find_changeset_by_name(r1)
333 333 %w|27 7bbf4c738e71 7bbf|.each do |r2|
334 334 assert_equal @repository.find_changeset_by_name(r2), changeset.previous
335 335 end
336 336 end
337 337 end
338 338
339 339 def test_previous_nil
340 340 assert_equal 0, @repository.changesets.count
341 341 @repository.fetch_changesets
342 342 @project.reload
343 343 assert_equal NUM_REV, @repository.changesets.count
344 344 %w|0 0885933ad4f6 0885|.each do |r1|
345 345 changeset = @repository.find_changeset_by_name(r1)
346 346 assert_nil changeset.previous
347 347 end
348 348 end
349 349
350 350 def test_next
351 351 assert_equal 0, @repository.changesets.count
352 352 @repository.fetch_changesets
353 353 @project.reload
354 354 assert_equal NUM_REV, @repository.changesets.count
355 355 %w|27 7bbf4c738e71 7bbf|.each do |r2|
356 356 changeset = @repository.find_changeset_by_name(r2)
357 357 %w|28 3ae45e2d177d 3ae45|.each do |r1|
358 358 assert_equal @repository.find_changeset_by_name(r1), changeset.next
359 359 end
360 360 end
361 361 end
362 362
363 363 def test_next_nil
364 364 assert_equal 0, @repository.changesets.count
365 365 @repository.fetch_changesets
366 366 @project.reload
367 367 assert_equal NUM_REV, @repository.changesets.count
368 368 %w|31 31eeee7395c8 31eee|.each do |r1|
369 369 changeset = @repository.find_changeset_by_name(r1)
370 370 assert_nil changeset.next
371 371 end
372 372 end
373 373 else
374 374 puts "Mercurial test repository NOT FOUND. Skipping unit tests !!!"
375 375 def test_fake; assert true end
376 376 end
377 377 end
@@ -1,231 +1,231
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 RepositorySubversionTest < ActiveSupport::TestCase
21 21 fixtures :projects, :repositories, :enabled_modules, :users, :roles
22 22
23 23 NUM_REV = 11
24 24
25 25 def setup
26 26 @project = Project.find(3)
27 27 @repository = Repository::Subversion.create(:project => @project,
28 28 :url => self.class.subversion_repository_url)
29 29 assert @repository
30 30 end
31 31
32 32 if repository_configured?('subversion')
33 33 def test_fetch_changesets_from_scratch
34 34 assert_equal 0, @repository.changesets.count
35 35 @repository.fetch_changesets
36 36 @project.reload
37 37
38 38 assert_equal NUM_REV, @repository.changesets.count
39 39 assert_equal 20, @repository.filechanges.count
40 40 assert_equal 'Initial import.', @repository.changesets.find_by_revision('1').comments
41 41 end
42 42
43 43 def test_fetch_changesets_incremental
44 44 assert_equal 0, @repository.changesets.count
45 45 @repository.fetch_changesets
46 46 @project.reload
47 47 assert_equal NUM_REV, @repository.changesets.count
48 48
49 49 # Remove changesets with revision > 5
50 @repository.changesets.find(:all).each {|c| c.destroy if c.revision.to_i > 5}
50 @repository.changesets.all.each {|c| c.destroy if c.revision.to_i > 5}
51 51 @project.reload
52 52 assert_equal 5, @repository.changesets.count
53 53
54 54 @repository.fetch_changesets
55 55 @project.reload
56 56 assert_equal NUM_REV, @repository.changesets.count
57 57 end
58 58
59 59 def test_entries
60 60 entries = @repository.entries
61 61 assert_kind_of Redmine::Scm::Adapters::Entries, entries
62 62 end
63 63
64 64 def test_entries_for_invalid_path_should_return_nil
65 65 entries = @repository.entries('invalid_path')
66 66 assert_nil entries
67 67 end
68 68
69 69 def test_latest_changesets
70 70 assert_equal 0, @repository.changesets.count
71 71 @repository.fetch_changesets
72 72 @project.reload
73 73 assert_equal NUM_REV, @repository.changesets.count
74 74
75 75 # with limit
76 76 changesets = @repository.latest_changesets('', nil, 2)
77 77 assert_equal 2, changesets.size
78 78 assert_equal @repository.latest_changesets('', nil).slice(0,2), changesets
79 79
80 80 # with path
81 81 changesets = @repository.latest_changesets('subversion_test/folder', nil)
82 82 assert_equal ["10", "9", "7", "6", "5", "2"], changesets.collect(&:revision)
83 83
84 84 # with path and revision
85 85 changesets = @repository.latest_changesets('subversion_test/folder', 8)
86 86 assert_equal ["7", "6", "5", "2"], changesets.collect(&:revision)
87 87 end
88 88
89 89 def test_directory_listing_with_square_brackets_in_path
90 90 assert_equal 0, @repository.changesets.count
91 91 @repository.fetch_changesets
92 92 @project.reload
93 93 assert_equal NUM_REV, @repository.changesets.count
94 94
95 95 entries = @repository.entries('subversion_test/[folder_with_brackets]')
96 96 assert_not_nil entries, 'Expect to find entries in folder_with_brackets'
97 97 assert_equal 1, entries.size, 'Expect one entry in folder_with_brackets'
98 98 assert_equal 'README.txt', entries.first.name
99 99 end
100 100
101 101 def test_directory_listing_with_square_brackets_in_base
102 102 @project = Project.find(3)
103 103 @repository = Repository::Subversion.create(
104 104 :project => @project,
105 105 :url => "file:///#{self.class.repository_path('subversion')}/subversion_test/[folder_with_brackets]")
106 106
107 107 assert_equal 0, @repository.changesets.count
108 108 @repository.fetch_changesets
109 109 @project.reload
110 110
111 111 assert_equal 1, @repository.changesets.count, 'Expected to see 1 revision'
112 112 assert_equal 2, @repository.filechanges.count, 'Expected to see 2 changes, dir add and file add'
113 113
114 114 entries = @repository.entries('')
115 115 assert_not_nil entries, 'Expect to find entries'
116 116 assert_equal 1, entries.size, 'Expect a single entry'
117 117 assert_equal 'README.txt', entries.first.name
118 118 end
119 119
120 120 def test_identifier
121 121 assert_equal 0, @repository.changesets.count
122 122 @repository.fetch_changesets
123 123 @project.reload
124 124 assert_equal NUM_REV, @repository.changesets.count
125 125 c = @repository.changesets.find_by_revision('1')
126 126 assert_equal c.revision, c.identifier
127 127 end
128 128
129 129 def test_find_changeset_by_empty_name
130 130 assert_equal 0, @repository.changesets.count
131 131 @repository.fetch_changesets
132 132 @project.reload
133 133 assert_equal NUM_REV, @repository.changesets.count
134 134 ['', ' ', nil].each do |r|
135 135 assert_nil @repository.find_changeset_by_name(r)
136 136 end
137 137 end
138 138
139 139 def test_identifier_nine_digit
140 140 c = Changeset.new(:repository => @repository, :committed_on => Time.now,
141 141 :revision => '123456789', :comments => 'test')
142 142 assert_equal c.identifier, c.revision
143 143 end
144 144
145 145 def test_format_identifier
146 146 assert_equal 0, @repository.changesets.count
147 147 @repository.fetch_changesets
148 148 @project.reload
149 149 assert_equal NUM_REV, @repository.changesets.count
150 150 c = @repository.changesets.find_by_revision('1')
151 151 assert_equal c.format_identifier, c.revision
152 152 end
153 153
154 154 def test_format_identifier_nine_digit
155 155 c = Changeset.new(:repository => @repository, :committed_on => Time.now,
156 156 :revision => '123456789', :comments => 'test')
157 157 assert_equal c.format_identifier, c.revision
158 158 end
159 159
160 160 def test_activities
161 161 c = Changeset.new(:repository => @repository, :committed_on => Time.now,
162 162 :revision => '1', :comments => 'test')
163 163 assert c.event_title.include?('1:')
164 164 assert_equal '1', c.event_url[:rev]
165 165 end
166 166
167 167 def test_activities_nine_digit
168 168 c = Changeset.new(:repository => @repository, :committed_on => Time.now,
169 169 :revision => '123456789', :comments => 'test')
170 170 assert c.event_title.include?('123456789:')
171 171 assert_equal '123456789', c.event_url[:rev]
172 172 end
173 173
174 174 def test_log_encoding_ignore_setting
175 175 with_settings :commit_logs_encoding => 'windows-1252' do
176 176 s1 = "\xC2\x80"
177 177 s2 = "\xc3\x82\xc2\x80"
178 178 if s1.respond_to?(:force_encoding)
179 179 s1.force_encoding('ISO-8859-1')
180 180 s2.force_encoding('UTF-8')
181 181 assert_equal s1.encode('UTF-8'), s2
182 182 end
183 183 c = Changeset.new(:repository => @repository,
184 184 :comments => s2,
185 185 :revision => '123',
186 186 :committed_on => Time.now)
187 187 assert c.save
188 188 assert_equal s2, c.comments
189 189 end
190 190 end
191 191
192 192 def test_previous
193 193 assert_equal 0, @repository.changesets.count
194 194 @repository.fetch_changesets
195 195 @project.reload
196 196 assert_equal NUM_REV, @repository.changesets.count
197 197 changeset = @repository.find_changeset_by_name('3')
198 198 assert_equal @repository.find_changeset_by_name('2'), changeset.previous
199 199 end
200 200
201 201 def test_previous_nil
202 202 assert_equal 0, @repository.changesets.count
203 203 @repository.fetch_changesets
204 204 @project.reload
205 205 assert_equal NUM_REV, @repository.changesets.count
206 206 changeset = @repository.find_changeset_by_name('1')
207 207 assert_nil changeset.previous
208 208 end
209 209
210 210 def test_next
211 211 assert_equal 0, @repository.changesets.count
212 212 @repository.fetch_changesets
213 213 @project.reload
214 214 assert_equal NUM_REV, @repository.changesets.count
215 215 changeset = @repository.find_changeset_by_name('2')
216 216 assert_equal @repository.find_changeset_by_name('3'), changeset.next
217 217 end
218 218
219 219 def test_next_nil
220 220 assert_equal 0, @repository.changesets.count
221 221 @repository.fetch_changesets
222 222 @project.reload
223 223 assert_equal NUM_REV, @repository.changesets.count
224 224 changeset = @repository.find_changeset_by_name('11')
225 225 assert_nil changeset.next
226 226 end
227 227 else
228 228 puts "Subversion test repository NOT FOUND. Skipping unit tests !!!"
229 229 def test_fake; assert true end
230 230 end
231 231 end
General Comments 0
You need to be logged in to leave comments. Login now