##// END OF EJS Templates
Merged r6199 from trunk....
Jean-Philippe Lang -
r6080:3e9ad22fade0
parent child
Show More
@@ -1,134 +1,135
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Enumeration < ActiveRecord::Base
19 19 default_scope :order => "#{Enumeration.table_name}.position ASC"
20 20
21 21 belongs_to :project
22 22
23 23 acts_as_list :scope => 'type = \'#{type}\''
24 24 acts_as_customizable
25 25 acts_as_tree :order => 'position ASC'
26 26
27 27 before_destroy :check_integrity
28 28
29 29 validates_presence_of :name
30 30 validates_uniqueness_of :name, :scope => [:type, :project_id]
31 31 validates_length_of :name, :maximum => 30
32 32
33 33 named_scope :shared, :conditions => { :project_id => nil }
34 34 named_scope :active, :conditions => { :active => true }
35 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
35 36
36 37 def self.default
37 38 # Creates a fake default scope so Enumeration.default will check
38 39 # it's type. STI subclasses will automatically add their own
39 40 # types to the finder.
40 41 if self.descends_from_active_record?
41 42 find(:first, :conditions => { :is_default => true, :type => 'Enumeration' })
42 43 else
43 44 # STI classes are
44 45 find(:first, :conditions => { :is_default => true })
45 46 end
46 47 end
47 48
48 49 # Overloaded on concrete classes
49 50 def option_name
50 51 nil
51 52 end
52 53
53 54 def before_save
54 55 if is_default? && is_default_changed?
55 56 Enumeration.update_all("is_default = #{connection.quoted_false}", {:type => type})
56 57 end
57 58 end
58 59
59 60 # Overloaded on concrete classes
60 61 def objects_count
61 62 0
62 63 end
63 64
64 65 def in_use?
65 66 self.objects_count != 0
66 67 end
67 68
68 69 # Is this enumeration overiding a system level enumeration?
69 70 def is_override?
70 71 !self.parent.nil?
71 72 end
72 73
73 74 alias :destroy_without_reassign :destroy
74 75
75 76 # Destroy the enumeration
76 77 # If a enumeration is specified, objects are reassigned
77 78 def destroy(reassign_to = nil)
78 79 if reassign_to && reassign_to.is_a?(Enumeration)
79 80 self.transfer_relations(reassign_to)
80 81 end
81 82 destroy_without_reassign
82 83 end
83 84
84 85 def <=>(enumeration)
85 86 position <=> enumeration.position
86 87 end
87 88
88 89 def to_s; name end
89 90
90 91 # Returns the Subclasses of Enumeration. Each Subclass needs to be
91 92 # required in development mode.
92 93 #
93 94 # Note: subclasses is protected in ActiveRecord
94 95 def self.get_subclasses
95 96 @@subclasses[Enumeration]
96 97 end
97 98
98 99 # Does the +new+ Hash override the previous Enumeration?
99 100 def self.overridding_change?(new, previous)
100 101 if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
101 102 return false
102 103 else
103 104 return true
104 105 end
105 106 end
106 107
107 108 # Does the +new+ Hash have the same custom values as the previous Enumeration?
108 109 def self.same_custom_values?(new, previous)
109 110 previous.custom_field_values.each do |custom_value|
110 111 if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
111 112 return false
112 113 end
113 114 end
114 115
115 116 return true
116 117 end
117 118
118 119 # Are the new and previous fields equal?
119 120 def self.same_active_state?(new, previous)
120 121 new = (new == "1" ? true : false)
121 122 return new == previous
122 123 end
123 124
124 125 private
125 126 def check_integrity
126 127 raise "Can't delete enumeration" if self.in_use?
127 128 end
128 129
129 130 end
130 131
131 132 # Force load the subclasses in development mode
132 133 require_dependency 'time_entry_activity'
133 134 require_dependency 'document_category'
134 135 require_dependency 'issue_priority'
@@ -1,43 +1,45
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 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 IssueCategory < ActiveRecord::Base
19 19 belongs_to :project
20 20 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
21 21 has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
22 22
23 23 validates_presence_of :name
24 24 validates_uniqueness_of :name, :scope => [:project_id]
25 25 validates_length_of :name, :maximum => 30
26 26
27 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
28
27 29 alias :destroy_without_reassign :destroy
28 30
29 31 # Destroy the category
30 32 # If a category is specified, issues are reassigned to this category
31 33 def destroy(reassign_to = nil)
32 34 if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
33 35 Issue.update_all("category_id = #{reassign_to.id}", "category_id = #{id}")
34 36 end
35 37 destroy_without_reassign
36 38 end
37 39
38 40 def <=>(category)
39 41 name <=> category.name
40 42 end
41 43
42 44 def to_s; name end
43 45 end
@@ -1,99 +1,101
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 IssueStatus < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 20 has_many :workflows, :foreign_key => "old_status_id"
21 21 acts_as_list
22 22
23 23 before_destroy :delete_workflows
24 24
25 25 validates_presence_of :name
26 26 validates_uniqueness_of :name
27 27 validates_length_of :name, :maximum => 30
28 28 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
29
30 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
29 31
30 32 def after_save
31 33 IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default?
32 34 end
33 35
34 36 # Returns the default status for new issues
35 37 def self.default
36 38 find(:first, :conditions =>["is_default=?", true])
37 39 end
38 40
39 41 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
40 42 def self.update_issue_done_ratios
41 43 if Issue.use_status_for_done_ratio?
42 44 IssueStatus.find(:all, :conditions => ["default_done_ratio >= 0"]).each do |status|
43 45 Issue.update_all(["done_ratio = ?", status.default_done_ratio],
44 46 ["status_id = ?", status.id])
45 47 end
46 48 end
47 49
48 50 return Issue.use_status_for_done_ratio?
49 51 end
50 52
51 53 # Returns an array of all statuses the given role can switch to
52 54 # Uses association cache when called more than one time
53 55 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
54 56 if roles && tracker
55 57 role_ids = roles.collect(&:id)
56 58 transitions = workflows.select do |w|
57 59 role_ids.include?(w.role_id) &&
58 60 w.tracker_id == tracker.id &&
59 61 (author || !w.author) &&
60 62 (assignee || !w.assignee)
61 63 end
62 64 transitions.collect{|w| w.new_status}.compact.sort
63 65 else
64 66 []
65 67 end
66 68 end
67 69
68 70 # Same thing as above but uses a database query
69 71 # More efficient than the previous method if called just once
70 72 def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
71 73 if roles && tracker
72 74 conditions = {:role_id => roles.collect(&:id), :tracker_id => tracker.id}
73 75 conditions[:author] = false unless author
74 76 conditions[:assignee] = false unless assignee
75 77
76 78 workflows.find(:all,
77 79 :include => :new_status,
78 80 :conditions => conditions).collect{|w| w.new_status}.compact.sort
79 81 else
80 82 []
81 83 end
82 84 end
83 85
84 86 def <=>(status)
85 87 position <=> status.position
86 88 end
87 89
88 90 def to_s; name end
89 91
90 92 private
91 93 def check_integrity
92 94 raise "Can't delete status" if Issue.find(:first, :conditions => ["status_id=?", self.id])
93 95 end
94 96
95 97 # Deletes associated workflows
96 98 def delete_workflows
97 99 Workflow.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
98 100 end
99 101 end
@@ -1,366 +1,366
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 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 @@handler_options[:allow_override] = @@handler_options[:allow_override].split(',').collect(&:strip) if @@handler_options[:allow_override].is_a?(String)
33 33 @@handler_options[:allow_override] ||= []
34 34 # Project needs to be overridable if not specified
35 35 @@handler_options[:allow_override] << 'project' unless @@handler_options[:issue].has_key?(:project)
36 36 # Status overridable by default
37 37 @@handler_options[:allow_override] << 'status' unless @@handler_options[:issue].has_key?(:status)
38 38
39 39 @@handler_options[:no_permission_check] = (@@handler_options[:no_permission_check].to_s == '1' ? true : false)
40 40 super email
41 41 end
42 42
43 43 # Processes incoming emails
44 44 # Returns the created object (eg. an issue, a message) or false
45 45 def receive(email)
46 46 @email = email
47 47 sender_email = email.from.to_a.first.to_s.strip
48 48 # Ignore emails received from the application emission address to avoid hell cycles
49 49 if sender_email.downcase == Setting.mail_from.to_s.strip.downcase
50 50 logger.info "MailHandler: ignoring email from Redmine emission address [#{sender_email}]" if logger && logger.info
51 51 return false
52 52 end
53 53 @user = User.find_by_mail(sender_email) if sender_email.present?
54 54 if @user && !@user.active?
55 55 logger.info "MailHandler: ignoring email from non-active user [#{@user.login}]" if logger && logger.info
56 56 return false
57 57 end
58 58 if @user.nil?
59 59 # Email was submitted by an unknown user
60 60 case @@handler_options[:unknown_user]
61 61 when 'accept'
62 62 @user = User.anonymous
63 63 when 'create'
64 64 @user = MailHandler.create_user_from_email(email)
65 65 if @user
66 66 logger.info "MailHandler: [#{@user.login}] account created" if logger && logger.info
67 67 Mailer.deliver_account_information(@user, @user.password)
68 68 else
69 69 logger.error "MailHandler: could not create account for [#{sender_email}]" if logger && logger.error
70 70 return false
71 71 end
72 72 else
73 73 # Default behaviour, emails from unknown users are ignored
74 74 logger.info "MailHandler: ignoring email from unknown user [#{sender_email}]" if logger && logger.info
75 75 return false
76 76 end
77 77 end
78 78 User.current = @user
79 79 dispatch
80 80 end
81 81
82 82 private
83 83
84 84 MESSAGE_ID_RE = %r{^<redmine\.([a-z0-9_]+)\-(\d+)\.\d+@}
85 85 ISSUE_REPLY_SUBJECT_RE = %r{\[[^\]]*#(\d+)\]}
86 86 MESSAGE_REPLY_SUBJECT_RE = %r{\[[^\]]*msg(\d+)\]}
87 87
88 88 def dispatch
89 89 headers = [email.in_reply_to, email.references].flatten.compact
90 90 if headers.detect {|h| h.to_s =~ MESSAGE_ID_RE}
91 91 klass, object_id = $1, $2.to_i
92 92 method_name = "receive_#{klass}_reply"
93 93 if self.class.private_instance_methods.collect(&:to_s).include?(method_name)
94 94 send method_name, object_id
95 95 else
96 96 # ignoring it
97 97 end
98 98 elsif m = email.subject.match(ISSUE_REPLY_SUBJECT_RE)
99 99 receive_issue_reply(m[1].to_i)
100 100 elsif m = email.subject.match(MESSAGE_REPLY_SUBJECT_RE)
101 101 receive_message_reply(m[1].to_i)
102 102 else
103 103 dispatch_to_default
104 104 end
105 105 rescue ActiveRecord::RecordInvalid => e
106 106 # TODO: send a email to the user
107 107 logger.error e.message if logger
108 108 false
109 109 rescue MissingInformation => e
110 110 logger.error "MailHandler: missing information from #{user}: #{e.message}" if logger
111 111 false
112 112 rescue UnauthorizedAction => e
113 113 logger.error "MailHandler: unauthorized attempt from #{user}" if logger
114 114 false
115 115 end
116 116
117 117 def dispatch_to_default
118 118 receive_issue
119 119 end
120 120
121 121 # Creates a new issue
122 122 def receive_issue
123 123 project = target_project
124 124 # check permission
125 125 unless @@handler_options[:no_permission_check]
126 126 raise UnauthorizedAction unless user.allowed_to?(:add_issues, project)
127 127 end
128 128
129 129 issue = Issue.new(:author => user, :project => project)
130 130 issue.safe_attributes = issue_attributes_from_keywords(issue)
131 131 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
132 132 issue.subject = email.subject.to_s.chomp[0,255]
133 133 if issue.subject.blank?
134 134 issue.subject = '(no subject)'
135 135 end
136 136 issue.description = cleaned_up_text_body
137 137
138 138 # add To and Cc as watchers before saving so the watchers can reply to Redmine
139 139 add_watchers(issue)
140 140 issue.save!
141 141 add_attachments(issue)
142 142 logger.info "MailHandler: issue ##{issue.id} created by #{user}" if logger && logger.info
143 143 issue
144 144 end
145 145
146 146 # Adds a note to an existing issue
147 147 def receive_issue_reply(issue_id)
148 148 issue = Issue.find_by_id(issue_id)
149 149 return unless issue
150 150 # check permission
151 151 unless @@handler_options[:no_permission_check]
152 152 raise UnauthorizedAction unless user.allowed_to?(:add_issue_notes, issue.project) || user.allowed_to?(:edit_issues, issue.project)
153 153 end
154 154
155 155 # ignore CLI-supplied defaults for new issues
156 156 @@handler_options[:issue].clear
157 157
158 158 journal = issue.init_journal(user)
159 159 issue.safe_attributes = issue_attributes_from_keywords(issue)
160 160 issue.safe_attributes = {'custom_field_values' => custom_field_values_from_keywords(issue)}
161 161 journal.notes = cleaned_up_text_body
162 162 add_attachments(issue)
163 163 issue.save!
164 164 logger.info "MailHandler: issue ##{issue.id} updated by #{user}" if logger && logger.info
165 165 journal
166 166 end
167 167
168 168 # Reply will be added to the issue
169 169 def receive_journal_reply(journal_id)
170 170 journal = Journal.find_by_id(journal_id)
171 171 if journal && journal.journalized_type == 'Issue'
172 172 receive_issue_reply(journal.journalized_id)
173 173 end
174 174 end
175 175
176 176 # Receives a reply to a forum message
177 177 def receive_message_reply(message_id)
178 178 message = Message.find_by_id(message_id)
179 179 if message
180 180 message = message.root
181 181
182 182 unless @@handler_options[:no_permission_check]
183 183 raise UnauthorizedAction unless user.allowed_to?(:add_messages, message.project)
184 184 end
185 185
186 186 if !message.locked?
187 187 reply = Message.new(:subject => email.subject.gsub(%r{^.*msg\d+\]}, '').strip,
188 188 :content => cleaned_up_text_body)
189 189 reply.author = user
190 190 reply.board = message.board
191 191 message.children << reply
192 192 add_attachments(reply)
193 193 reply
194 194 else
195 195 logger.info "MailHandler: ignoring reply from [#{sender_email}] to a locked topic" if logger && logger.info
196 196 end
197 197 end
198 198 end
199 199
200 200 def add_attachments(obj)
201 201 if email.has_attachments?
202 202 email.attachments.each do |attachment|
203 203 Attachment.create(:container => obj,
204 204 :file => attachment,
205 205 :author => user,
206 206 :content_type => attachment.content_type)
207 207 end
208 208 end
209 209 end
210 210
211 211 # Adds To and Cc as watchers of the given object if the sender has the
212 212 # appropriate permission
213 213 def add_watchers(obj)
214 214 if user.allowed_to?("add_#{obj.class.name.underscore}_watchers".to_sym, obj.project)
215 215 addresses = [email.to, email.cc].flatten.compact.uniq.collect {|a| a.strip.downcase}
216 216 unless addresses.empty?
217 217 watchers = User.active.find(:all, :conditions => ['LOWER(mail) IN (?)', addresses])
218 218 watchers.each {|w| obj.add_watcher(w)}
219 219 end
220 220 end
221 221 end
222 222
223 223 def get_keyword(attr, options={})
224 224 @keywords ||= {}
225 225 if @keywords.has_key?(attr)
226 226 @keywords[attr]
227 227 else
228 228 @keywords[attr] = begin
229 229 if (options[:override] || @@handler_options[:allow_override].include?(attr.to_s)) && (v = extract_keyword!(plain_text_body, attr, options[:format]))
230 230 v
231 231 elsif !@@handler_options[:issue][attr].blank?
232 232 @@handler_options[:issue][attr]
233 233 end
234 234 end
235 235 end
236 236 end
237 237
238 238 # Destructively extracts the value for +attr+ in +text+
239 239 # Returns nil if no matching keyword found
240 240 def extract_keyword!(text, attr, format=nil)
241 241 keys = [attr.to_s.humanize]
242 242 if attr.is_a?(Symbol)
243 243 keys << l("field_#{attr}", :default => '', :locale => user.language) if user && user.language.present?
244 244 keys << l("field_#{attr}", :default => '', :locale => Setting.default_language) if Setting.default_language.present?
245 245 end
246 246 keys.reject! {|k| k.blank?}
247 247 keys.collect! {|k| Regexp.escape(k)}
248 248 format ||= '.+'
249 249 text.gsub!(/^(#{keys.join('|')})[ \t]*:[ \t]*(#{format})\s*$/i, '')
250 250 $2 && $2.strip
251 251 end
252 252
253 253 def target_project
254 254 # TODO: other ways to specify project:
255 255 # * parse the email To field
256 256 # * specific project (eg. Setting.mail_handler_target_project)
257 257 target = Project.find_by_identifier(get_keyword(:project))
258 258 raise MissingInformation.new('Unable to determine target project') if target.nil?
259 259 target
260 260 end
261 261
262 262 # Returns a Hash of issue attributes extracted from keywords in the email body
263 263 def issue_attributes_from_keywords(issue)
264 264 assigned_to = (k = get_keyword(:assigned_to, :override => true)) && find_user_from_keyword(k)
265 265 assigned_to = nil if assigned_to && !issue.assignable_users.include?(assigned_to)
266 266
267 267 attrs = {
268 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.find_by_name(k).try(:id),
269 'status_id' => (k = get_keyword(:status)) && IssueStatus.find_by_name(k).try(:id),
270 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.find_by_name(k).try(:id),
271 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.find_by_name(k).try(:id),
268 'tracker_id' => (k = get_keyword(:tracker)) && issue.project.trackers.named(k).first.try(:id),
269 'status_id' => (k = get_keyword(:status)) && IssueStatus.named(k).first.try(:id),
270 'priority_id' => (k = get_keyword(:priority)) && IssuePriority.named(k).first.try(:id),
271 'category_id' => (k = get_keyword(:category)) && issue.project.issue_categories.named(k).first.try(:id),
272 272 'assigned_to_id' => assigned_to.try(:id),
273 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.find_by_name(k).try(:id),
273 'fixed_version_id' => (k = get_keyword(:fixed_version, :override => true)) && issue.project.shared_versions.named(k).first.try(:id),
274 274 'start_date' => get_keyword(:start_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
275 275 'due_date' => get_keyword(:due_date, :override => true, :format => '\d{4}-\d{2}-\d{2}'),
276 276 'estimated_hours' => get_keyword(:estimated_hours, :override => true),
277 277 'done_ratio' => get_keyword(:done_ratio, :override => true, :format => '(\d|10)?0')
278 278 }.delete_if {|k, v| v.blank? }
279 279
280 280 if issue.new_record? && attrs['tracker_id'].nil?
281 281 attrs['tracker_id'] = issue.project.trackers.find(:first).try(:id)
282 282 end
283 283
284 284 attrs
285 285 end
286 286
287 287 # Returns a Hash of issue custom field values extracted from keywords in the email body
288 288 def custom_field_values_from_keywords(customized)
289 289 customized.custom_field_values.inject({}) do |h, v|
290 290 if value = get_keyword(v.custom_field.name, :override => true)
291 291 h[v.custom_field.id.to_s] = value
292 292 end
293 293 h
294 294 end
295 295 end
296 296
297 297 # Returns the text/plain part of the email
298 298 # If not found (eg. HTML-only email), returns the body with tags removed
299 299 def plain_text_body
300 300 return @plain_text_body unless @plain_text_body.nil?
301 301 parts = @email.parts.collect {|c| (c.respond_to?(:parts) && !c.parts.empty?) ? c.parts : c}.flatten
302 302 if parts.empty?
303 303 parts << @email
304 304 end
305 305 plain_text_part = parts.detect {|p| p.content_type == 'text/plain'}
306 306 if plain_text_part.nil?
307 307 # no text/plain part found, assuming html-only email
308 308 # strip html tags and remove doctype directive
309 309 @plain_text_body = strip_tags(@email.body.to_s)
310 310 @plain_text_body.gsub! %r{^<!DOCTYPE .*$}, ''
311 311 else
312 312 @plain_text_body = plain_text_part.body.to_s
313 313 end
314 314 @plain_text_body.strip!
315 315 @plain_text_body
316 316 end
317 317
318 318 def cleaned_up_text_body
319 319 cleanup_body(plain_text_body)
320 320 end
321 321
322 322 def self.full_sanitizer
323 323 @full_sanitizer ||= HTML::FullSanitizer.new
324 324 end
325 325
326 326 # Creates a user account for the +email+ sender
327 327 def self.create_user_from_email(email)
328 328 addr = email.from_addrs.to_a.first
329 329 if addr && !addr.spec.blank?
330 330 user = User.new
331 331 user.mail = addr.spec
332 332
333 333 names = addr.name.blank? ? addr.spec.gsub(/@.*$/, '').split('.') : addr.name.split
334 334 user.firstname = names.shift
335 335 user.lastname = names.join(' ')
336 336 user.lastname = '-' if user.lastname.blank?
337 337
338 338 user.login = user.mail
339 339 user.password = ActiveSupport::SecureRandom.hex(5)
340 340 user.language = Setting.default_language
341 341 user.save ? user : nil
342 342 end
343 343 end
344 344
345 345 private
346 346
347 347 # Removes the email body of text after the truncation configurations.
348 348 def cleanup_body(body)
349 349 delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).reject(&:blank?).map {|s| Regexp.escape(s)}
350 350 unless delimiters.empty?
351 351 regex = Regexp.new("^[> ]*(#{ delimiters.join('|') })\s*[\r\n].*", Regexp::MULTILINE)
352 352 body = body.gsub(regex, '')
353 353 end
354 354 body.strip
355 355 end
356 356
357 357 def find_user_from_keyword(keyword)
358 358 user ||= User.find_by_mail(keyword)
359 359 user ||= User.find_by_login(keyword)
360 360 if user.nil? && keyword.match(/ /)
361 361 firstname, lastname = *(keyword.split) # "First Last Throwaway"
362 362 user ||= User.find_by_firstname_and_lastname(firstname, lastname)
363 363 end
364 364 user
365 365 end
366 366 end
@@ -1,66 +1,68
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Tracker < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 20 has_many :issues
21 21 has_many :workflows, :dependent => :delete_all do
22 22 def copy(source_tracker)
23 23 Workflow.copy(source_tracker, nil, proxy_owner, nil)
24 24 end
25 25 end
26 26
27 27 has_and_belongs_to_many :projects
28 28 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
29 29 acts_as_list
30 30
31 31 validates_presence_of :name
32 32 validates_uniqueness_of :name
33 33 validates_length_of :name, :maximum => 30
34 34
35 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
36
35 37 def to_s; name end
36 38
37 39 def <=>(tracker)
38 40 name <=> tracker.name
39 41 end
40 42
41 43 def self.all
42 44 find(:all, :order => 'position')
43 45 end
44 46
45 47 # Returns an array of IssueStatus that are used
46 48 # in the tracker's workflows
47 49 def issue_statuses
48 50 if @issue_statuses
49 51 return @issue_statuses
50 52 elsif new_record?
51 53 return []
52 54 end
53 55
54 56 ids = Workflow.
55 57 connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{Workflow.table_name} WHERE tracker_id = #{id}").
56 58 flatten.
57 59 uniq
58 60
59 61 @issue_statuses = IssueStatus.find_all_by_id(ids).sort
60 62 end
61 63
62 64 private
63 65 def check_integrity
64 66 raise "Can't delete tracker" if Issue.find(:first, :conditions => ["tracker_id=?", self.id])
65 67 end
66 68 end
@@ -1,233 +1,234
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2010 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 Version < ActiveRecord::Base
19 19 after_update :update_issues_from_sharing_change
20 20 belongs_to :project
21 21 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
22 22 acts_as_customizable
23 23 acts_as_attachable :view_permission => :view_files,
24 24 :delete_permission => :manage_files
25 25
26 26 VERSION_STATUSES = %w(open locked closed)
27 27 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
28 28
29 29 validates_presence_of :name
30 30 validates_uniqueness_of :name, :scope => [:project_id]
31 31 validates_length_of :name, :maximum => 60
32 32 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
33 33 validates_inclusion_of :status, :in => VERSION_STATUSES
34 34 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
35 35
36 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
36 37 named_scope :open, :conditions => {:status => 'open'}
37 38 named_scope :visible, lambda {|*args| { :include => :project,
38 39 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
39 40
40 41 # Returns true if +user+ or current user is allowed to view the version
41 42 def visible?(user=User.current)
42 43 user.allowed_to?(:view_issues, self.project)
43 44 end
44 45
45 46 def start_date
46 47 @start_date ||= fixed_issues.minimum('start_date')
47 48 end
48 49
49 50 def due_date
50 51 effective_date
51 52 end
52 53
53 54 # Returns the total estimated time for this version
54 55 # (sum of leaves estimated_hours)
55 56 def estimated_hours
56 57 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
57 58 end
58 59
59 60 # Returns the total reported time for this version
60 61 def spent_hours
61 62 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
62 63 end
63 64
64 65 def closed?
65 66 status == 'closed'
66 67 end
67 68
68 69 def open?
69 70 status == 'open'
70 71 end
71 72
72 73 # Returns true if the version is completed: due date reached and no open issues
73 74 def completed?
74 75 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
75 76 end
76 77
77 78 def behind_schedule?
78 79 if completed_pourcent == 100
79 80 return false
80 81 elsif due_date && start_date
81 82 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
82 83 return done_date <= Date.today
83 84 else
84 85 false # No issues so it's not late
85 86 end
86 87 end
87 88
88 89 # Returns the completion percentage of this version based on the amount of open/closed issues
89 90 # and the time spent on the open issues.
90 91 def completed_pourcent
91 92 if issues_count == 0
92 93 0
93 94 elsif open_issues_count == 0
94 95 100
95 96 else
96 97 issues_progress(false) + issues_progress(true)
97 98 end
98 99 end
99 100
100 101 # Returns the percentage of issues that have been marked as 'closed'.
101 102 def closed_pourcent
102 103 if issues_count == 0
103 104 0
104 105 else
105 106 issues_progress(false)
106 107 end
107 108 end
108 109
109 110 # Returns true if the version is overdue: due date reached and some open issues
110 111 def overdue?
111 112 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
112 113 end
113 114
114 115 # Returns assigned issues count
115 116 def issues_count
116 117 @issue_count ||= fixed_issues.count
117 118 end
118 119
119 120 # Returns the total amount of open issues for this version.
120 121 def open_issues_count
121 122 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
122 123 end
123 124
124 125 # Returns the total amount of closed issues for this version.
125 126 def closed_issues_count
126 127 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
127 128 end
128 129
129 130 def wiki_page
130 131 if project.wiki && !wiki_page_title.blank?
131 132 @wiki_page ||= project.wiki.find_page(wiki_page_title)
132 133 end
133 134 @wiki_page
134 135 end
135 136
136 137 def to_s; name end
137 138
138 139 def to_s_with_project
139 140 "#{project} - #{name}"
140 141 end
141 142
142 143 # Versions are sorted by effective_date and "Project Name - Version name"
143 144 # Those with no effective_date are at the end, sorted by "Project Name - Version name"
144 145 def <=>(version)
145 146 if self.effective_date
146 147 if version.effective_date
147 148 if self.effective_date == version.effective_date
148 149 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
149 150 else
150 151 self.effective_date <=> version.effective_date
151 152 end
152 153 else
153 154 -1
154 155 end
155 156 else
156 157 if version.effective_date
157 158 1
158 159 else
159 160 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
160 161 end
161 162 end
162 163 end
163 164
164 165 # Returns the sharings that +user+ can set the version to
165 166 def allowed_sharings(user = User.current)
166 167 VERSION_SHARINGS.select do |s|
167 168 if sharing == s
168 169 true
169 170 else
170 171 case s
171 172 when 'system'
172 173 # Only admin users can set a systemwide sharing
173 174 user.admin?
174 175 when 'hierarchy', 'tree'
175 176 # Only users allowed to manage versions of the root project can
176 177 # set sharing to hierarchy or tree
177 178 project.nil? || user.allowed_to?(:manage_versions, project.root)
178 179 else
179 180 true
180 181 end
181 182 end
182 183 end
183 184 end
184 185
185 186 private
186 187
187 188 # Update the issue's fixed versions. Used if a version's sharing changes.
188 189 def update_issues_from_sharing_change
189 190 if sharing_changed?
190 191 if VERSION_SHARINGS.index(sharing_was).nil? ||
191 192 VERSION_SHARINGS.index(sharing).nil? ||
192 193 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
193 194 Issue.update_versions_from_sharing_change self
194 195 end
195 196 end
196 197 end
197 198
198 199 # Returns the average estimated time of assigned issues
199 200 # or 1 if no issue has an estimated time
200 201 # Used to weigth unestimated issues in progress calculation
201 202 def estimated_average
202 203 if @estimated_average.nil?
203 204 average = fixed_issues.average(:estimated_hours).to_f
204 205 if average == 0
205 206 average = 1
206 207 end
207 208 @estimated_average = average
208 209 end
209 210 @estimated_average
210 211 end
211 212
212 213 # Returns the total progress of open or closed issues. The returned percentage takes into account
213 214 # the amount of estimated time set for this version.
214 215 #
215 216 # Examples:
216 217 # issues_progress(true) => returns the progress percentage for open issues.
217 218 # issues_progress(false) => returns the progress percentage for closed issues.
218 219 def issues_progress(open)
219 220 @issues_progress ||= {}
220 221 @issues_progress[open] ||= begin
221 222 progress = 0
222 223 if issues_count > 0
223 224 ratio = open ? 'done_ratio' : 100
224 225
225 226 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
226 227 :include => :status,
227 228 :conditions => ["is_closed = ?", !open]).to_f
228 229 progress = done / (estimated_average * issues_count)
229 230 end
230 231 progress
231 232 end
232 233 end
233 234 end
@@ -1,43 +1,43
1 1 Return-Path: <jsmith@somenet.foo>
2 2 Received: from osiris ([127.0.0.1])
3 3 by OSIRIS
4 4 with hMailServer ; Sun, 22 Jun 2008 12:28:07 +0200
5 5 Message-ID: <000501c8d452$a95cd7e0$0a00a8c0@osiris>
6 6 From: "John Smith" <jsmith@somenet.foo>
7 7 To: <redmine@somenet.foo>
8 8 Subject: New ticket on a given project
9 9 Date: Sun, 22 Jun 2008 12:28:07 +0200
10 10 MIME-Version: 1.0
11 11 Content-Type: text/plain;
12 12 format=flowed;
13 13 charset="iso-8859-1";
14 14 reply-type=original
15 15 Content-Transfer-Encoding: 7bit
16 16 X-Priority: 3
17 17 X-MSMail-Priority: Normal
18 18 X-Mailer: Microsoft Outlook Express 6.00.2900.2869
19 19 X-MimeOLE: Produced By Microsoft MimeOLE V6.00.2900.2869
20 20
21 21 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas imperdiet
22 22 turpis et odio. Integer eget pede vel dolor euismod varius. Phasellus
23 23 blandit eleifend augue. Nulla facilisi. Duis id diam. Class aptent taciti
24 24 sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. In
25 25 in urna sed tellus aliquet lobortis. Morbi scelerisque tortor in dolor. Cras
26 26 sagittis odio eu lacus. Aliquam sem tortor, consequat sit amet, vestibulum
27 27 id, iaculis at, lectus. Fusce tortor libero, congue ut, euismod nec, luctus
28 28 eget, eros. Pellentesque tortor enim, feugiat in, dignissim eget, tristique
29 29 sed, mauris. Pellentesque habitant morbi tristique senectus et netus et
30 30 malesuada fames ac turpis egestas. Quisque sit amet libero. In hac habitasse
31 31 platea dictumst.
32 32
33 33 Nulla et nunc. Duis pede. Donec et ipsum. Nam ut dui tincidunt neque
34 34 sollicitudin iaculis. Duis vitae dolor. Vestibulum eget massa. Sed lorem.
35 35 Nullam volutpat cursus erat. Cras felis dolor, lacinia quis, rutrum et,
36 36 dictum et, ligula. Sed erat nibh, gravida in, accumsan non, placerat sed,
37 37 massa. Sed sodales, ante fermentum ultricies sollicitudin, massa leo
38 38 pulvinar dui, a gravida orci mi eget odio. Nunc a lacus.
39 39
40 40 Project: onlinestore
41 Tracker: Feature request
42 category: Stock management
43 priority: Urgent
41 Tracker: Feature Request
42 category: stock management
43 priority: URGENT
@@ -1,457 +1,457
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2011 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 MailHandlerTest < ActiveSupport::TestCase
23 23 fixtures :users, :projects,
24 24 :enabled_modules,
25 25 :roles,
26 26 :members,
27 27 :member_roles,
28 28 :users,
29 29 :issues,
30 30 :issue_statuses,
31 31 :workflows,
32 32 :trackers,
33 33 :projects_trackers,
34 34 :versions,
35 35 :enumerations,
36 36 :issue_categories,
37 37 :custom_fields,
38 38 :custom_fields_trackers,
39 39 :custom_fields_projects,
40 40 :boards,
41 41 :messages
42 42
43 43 FIXTURES_PATH = File.dirname(__FILE__) + '/../fixtures/mail_handler'
44 44
45 45 def setup
46 46 ActionMailer::Base.deliveries.clear
47 47 Setting.notified_events = Redmine::Notifiable.all.collect(&:name)
48 48 end
49 49
50 50 def test_add_issue
51 51 ActionMailer::Base.deliveries.clear
52 52 # This email contains: 'Project: onlinestore'
53 53 issue = submit_email('ticket_on_given_project.eml')
54 54 assert issue.is_a?(Issue)
55 55 assert !issue.new_record?
56 56 issue.reload
57 57 assert_equal Project.find(2), issue.project
58 58 assert_equal issue.project.trackers.first, issue.tracker
59 59 assert_equal 'New ticket on a given project', issue.subject
60 60 assert_equal User.find_by_login('jsmith'), issue.author
61 61 assert_equal IssueStatus.find_by_name('Resolved'), issue.status
62 62 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
63 63 assert_equal '2010-01-01', issue.start_date.to_s
64 64 assert_equal '2010-12-31', issue.due_date.to_s
65 65 assert_equal User.find_by_login('jsmith'), issue.assigned_to
66 assert_equal Version.find_by_name('alpha'), issue.fixed_version
66 assert_equal Version.find_by_name('Alpha'), issue.fixed_version
67 67 assert_equal 2.5, issue.estimated_hours
68 68 assert_equal 30, issue.done_ratio
69 69 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
70 70 # keywords should be removed from the email body
71 71 assert !issue.description.match(/^Project:/i)
72 72 assert !issue.description.match(/^Status:/i)
73 73 assert !issue.description.match(/^Start Date:/i)
74 74 # Email notification should be sent
75 75 mail = ActionMailer::Base.deliveries.last
76 76 assert_not_nil mail
77 77 assert mail.subject.include?('New ticket on a given project')
78 78 end
79 79
80 80 def test_add_issue_with_default_tracker
81 81 # This email contains: 'Project: onlinestore'
82 82 issue = submit_email('ticket_on_given_project.eml', :issue => {:tracker => 'Support request'})
83 83 assert issue.is_a?(Issue)
84 84 assert !issue.new_record?
85 85 issue.reload
86 86 assert_equal 'Support request', issue.tracker.name
87 87 end
88 88
89 89 def test_add_issue_with_status
90 90 # This email contains: 'Project: onlinestore' and 'Status: Resolved'
91 91 issue = submit_email('ticket_on_given_project.eml')
92 92 assert issue.is_a?(Issue)
93 93 assert !issue.new_record?
94 94 issue.reload
95 95 assert_equal Project.find(2), issue.project
96 96 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
97 97 end
98 98
99 99 def test_add_issue_with_attributes_override
100 100 issue = submit_email('ticket_with_attributes.eml', :allow_override => 'tracker,category,priority')
101 101 assert issue.is_a?(Issue)
102 102 assert !issue.new_record?
103 103 issue.reload
104 104 assert_equal 'New ticket on a given project', issue.subject
105 105 assert_equal User.find_by_login('jsmith'), issue.author
106 106 assert_equal Project.find(2), issue.project
107 107 assert_equal 'Feature request', issue.tracker.to_s
108 108 assert_equal 'Stock management', issue.category.to_s
109 109 assert_equal 'Urgent', issue.priority.to_s
110 110 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
111 111 end
112 112
113 113 def test_add_issue_with_partial_attributes_override
114 114 issue = submit_email('ticket_with_attributes.eml', :issue => {:priority => 'High'}, :allow_override => ['tracker'])
115 115 assert issue.is_a?(Issue)
116 116 assert !issue.new_record?
117 117 issue.reload
118 118 assert_equal 'New ticket on a given project', issue.subject
119 119 assert_equal User.find_by_login('jsmith'), issue.author
120 120 assert_equal Project.find(2), issue.project
121 121 assert_equal 'Feature request', issue.tracker.to_s
122 122 assert_nil issue.category
123 123 assert_equal 'High', issue.priority.to_s
124 124 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
125 125 end
126 126
127 127 def test_add_issue_with_spaces_between_attribute_and_separator
128 128 issue = submit_email('ticket_with_spaces_between_attribute_and_separator.eml', :allow_override => 'tracker,category,priority')
129 129 assert issue.is_a?(Issue)
130 130 assert !issue.new_record?
131 131 issue.reload
132 132 assert_equal 'New ticket on a given project', issue.subject
133 133 assert_equal User.find_by_login('jsmith'), issue.author
134 134 assert_equal Project.find(2), issue.project
135 135 assert_equal 'Feature request', issue.tracker.to_s
136 136 assert_equal 'Stock management', issue.category.to_s
137 137 assert_equal 'Urgent', issue.priority.to_s
138 138 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
139 139 end
140 140
141 141 def test_add_issue_with_attachment_to_specific_project
142 142 issue = submit_email('ticket_with_attachment.eml', :issue => {:project => 'onlinestore'})
143 143 assert issue.is_a?(Issue)
144 144 assert !issue.new_record?
145 145 issue.reload
146 146 assert_equal 'Ticket created by email with attachment', issue.subject
147 147 assert_equal User.find_by_login('jsmith'), issue.author
148 148 assert_equal Project.find(2), issue.project
149 149 assert_equal 'This is a new ticket with attachments', issue.description
150 150 # Attachment properties
151 151 assert_equal 1, issue.attachments.size
152 152 assert_equal 'Paella.jpg', issue.attachments.first.filename
153 153 assert_equal 'image/jpeg', issue.attachments.first.content_type
154 154 assert_equal 10790, issue.attachments.first.filesize
155 155 end
156 156
157 157 def test_add_issue_with_custom_fields
158 158 issue = submit_email('ticket_with_custom_fields.eml', :issue => {:project => 'onlinestore'})
159 159 assert issue.is_a?(Issue)
160 160 assert !issue.new_record?
161 161 issue.reload
162 162 assert_equal 'New ticket with custom field values', issue.subject
163 163 assert_equal 'Value for a custom field', issue.custom_value_for(CustomField.find_by_name('Searchable field')).value
164 164 assert !issue.description.match(/^searchable field:/i)
165 165 end
166 166
167 167 def test_add_issue_with_cc
168 168 issue = submit_email('ticket_with_cc.eml', :issue => {:project => 'ecookbook'})
169 169 assert issue.is_a?(Issue)
170 170 assert !issue.new_record?
171 171 issue.reload
172 172 assert issue.watched_by?(User.find_by_mail('dlopper@somenet.foo'))
173 173 assert_equal 1, issue.watcher_user_ids.size
174 174 end
175 175
176 176 def test_add_issue_by_unknown_user
177 177 assert_no_difference 'User.count' do
178 178 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'})
179 179 end
180 180 end
181 181
182 182 def test_add_issue_by_anonymous_user
183 183 Role.anonymous.add_permission!(:add_issues)
184 184 assert_no_difference 'User.count' do
185 185 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
186 186 assert issue.is_a?(Issue)
187 187 assert issue.author.anonymous?
188 188 end
189 189 end
190 190
191 191 def test_add_issue_by_anonymous_user_with_no_from_address
192 192 Role.anonymous.add_permission!(:add_issues)
193 193 assert_no_difference 'User.count' do
194 194 issue = submit_email('ticket_by_empty_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'accept')
195 195 assert issue.is_a?(Issue)
196 196 assert issue.author.anonymous?
197 197 end
198 198 end
199 199
200 200 def test_add_issue_by_anonymous_user_on_private_project
201 201 Role.anonymous.add_permission!(:add_issues)
202 202 assert_no_difference 'User.count' do
203 203 assert_no_difference 'Issue.count' do
204 204 assert_equal false, submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :unknown_user => 'accept')
205 205 end
206 206 end
207 207 end
208 208
209 209 def test_add_issue_by_anonymous_user_on_private_project_without_permission_check
210 210 assert_no_difference 'User.count' do
211 211 assert_difference 'Issue.count' do
212 212 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'onlinestore'}, :no_permission_check => '1', :unknown_user => 'accept')
213 213 assert issue.is_a?(Issue)
214 214 assert issue.author.anonymous?
215 215 assert !issue.project.is_public?
216 216 assert_equal [issue.id, 1, 2], [issue.root_id, issue.lft, issue.rgt]
217 217 end
218 218 end
219 219 end
220 220
221 221 def test_add_issue_by_created_user
222 222 Setting.default_language = 'en'
223 223 assert_difference 'User.count' do
224 224 issue = submit_email('ticket_by_unknown_user.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
225 225 assert issue.is_a?(Issue)
226 226 assert issue.author.active?
227 227 assert_equal 'john.doe@somenet.foo', issue.author.mail
228 228 assert_equal 'John', issue.author.firstname
229 229 assert_equal 'Doe', issue.author.lastname
230 230
231 231 # account information
232 232 email = ActionMailer::Base.deliveries.first
233 233 assert_not_nil email
234 234 assert email.subject.include?('account activation')
235 235 login = email.body.match(/\* Login: (.*)$/)[1]
236 236 password = email.body.match(/\* Password: (.*)$/)[1]
237 237 assert_equal issue.author, User.try_to_login(login, password)
238 238 end
239 239 end
240 240
241 241 def test_add_issue_without_from_header
242 242 Role.anonymous.add_permission!(:add_issues)
243 243 assert_equal false, submit_email('ticket_without_from_header.eml')
244 244 end
245 245
246 246 def test_add_issue_with_invalid_attributes
247 247 issue = submit_email('ticket_with_invalid_attributes.eml', :allow_override => 'tracker,category,priority')
248 248 assert issue.is_a?(Issue)
249 249 assert !issue.new_record?
250 250 issue.reload
251 251 assert_nil issue.assigned_to
252 252 assert_nil issue.start_date
253 253 assert_nil issue.due_date
254 254 assert_equal 0, issue.done_ratio
255 255 assert_equal 'Normal', issue.priority.to_s
256 256 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
257 257 end
258 258
259 259 def test_add_issue_with_localized_attributes
260 260 User.find_by_mail('jsmith@somenet.foo').update_attribute 'language', 'fr'
261 261 issue = submit_email('ticket_with_localized_attributes.eml', :allow_override => 'tracker,category,priority')
262 262 assert issue.is_a?(Issue)
263 263 assert !issue.new_record?
264 264 issue.reload
265 265 assert_equal 'New ticket on a given project', issue.subject
266 266 assert_equal User.find_by_login('jsmith'), issue.author
267 267 assert_equal Project.find(2), issue.project
268 268 assert_equal 'Feature request', issue.tracker.to_s
269 269 assert_equal 'Stock management', issue.category.to_s
270 270 assert_equal 'Urgent', issue.priority.to_s
271 271 assert issue.description.include?('Lorem ipsum dolor sit amet, consectetuer adipiscing elit.')
272 272 end
273 273
274 274 def test_add_issue_with_japanese_keywords
275 275 tracker = Tracker.create!(:name => 'ι–‹η™Ί')
276 276 Project.find(1).trackers << tracker
277 277 issue = submit_email('japanese_keywords_iso_2022_jp.eml', :issue => {:project => 'ecookbook'}, :allow_override => 'tracker')
278 278 assert_kind_of Issue, issue
279 279 assert_equal tracker, issue.tracker
280 280 end
281 281
282 282 def test_should_ignore_emails_from_emission_address
283 283 Role.anonymous.add_permission!(:add_issues)
284 284 assert_no_difference 'User.count' do
285 285 assert_equal false, submit_email('ticket_from_emission_address.eml', :issue => {:project => 'ecookbook'}, :unknown_user => 'create')
286 286 end
287 287 end
288 288
289 289 def test_add_issue_should_send_email_notification
290 290 Setting.notified_events = ['issue_added']
291 291 ActionMailer::Base.deliveries.clear
292 292 # This email contains: 'Project: onlinestore'
293 293 issue = submit_email('ticket_on_given_project.eml')
294 294 assert issue.is_a?(Issue)
295 295 assert_equal 1, ActionMailer::Base.deliveries.size
296 296 end
297 297
298 298 def test_add_issue_note
299 299 journal = submit_email('ticket_reply.eml')
300 300 assert journal.is_a?(Journal)
301 301 assert_equal User.find_by_login('jsmith'), journal.user
302 302 assert_equal Issue.find(2), journal.journalized
303 303 assert_match /This is reply/, journal.notes
304 304 assert_equal 'Feature request', journal.issue.tracker.name
305 305 end
306 306
307 307 def test_add_issue_note_with_attribute_changes
308 308 # This email contains: 'Status: Resolved'
309 309 journal = submit_email('ticket_reply_with_status.eml')
310 310 assert journal.is_a?(Journal)
311 311 issue = Issue.find(journal.issue.id)
312 312 assert_equal User.find_by_login('jsmith'), journal.user
313 313 assert_equal Issue.find(2), journal.journalized
314 314 assert_match /This is reply/, journal.notes
315 315 assert_equal 'Feature request', journal.issue.tracker.name
316 316 assert_equal IssueStatus.find_by_name("Resolved"), issue.status
317 317 assert_equal '2010-01-01', issue.start_date.to_s
318 318 assert_equal '2010-12-31', issue.due_date.to_s
319 319 assert_equal User.find_by_login('jsmith'), issue.assigned_to
320 320 assert_equal "52.6", issue.custom_value_for(CustomField.find_by_name('Float field')).value
321 321 # keywords should be removed from the email body
322 322 assert !journal.notes.match(/^Status:/i)
323 323 assert !journal.notes.match(/^Start Date:/i)
324 324 end
325 325
326 326 def test_add_issue_note_should_send_email_notification
327 327 ActionMailer::Base.deliveries.clear
328 328 journal = submit_email('ticket_reply.eml')
329 329 assert journal.is_a?(Journal)
330 330 assert_equal 1, ActionMailer::Base.deliveries.size
331 331 end
332 332
333 333 def test_add_issue_note_should_not_set_defaults
334 334 journal = submit_email('ticket_reply.eml', :issue => {:tracker => 'Support request', :priority => 'High'})
335 335 assert journal.is_a?(Journal)
336 336 assert_match /This is reply/, journal.notes
337 337 assert_equal 'Feature request', journal.issue.tracker.name
338 338 assert_equal 'Normal', journal.issue.priority.name
339 339 end
340 340
341 341 def test_reply_to_a_message
342 342 m = submit_email('message_reply.eml')
343 343 assert m.is_a?(Message)
344 344 assert !m.new_record?
345 345 m.reload
346 346 assert_equal 'Reply via email', m.subject
347 347 # The email replies to message #2 which is part of the thread of message #1
348 348 assert_equal Message.find(1), m.parent
349 349 end
350 350
351 351 def test_reply_to_a_message_by_subject
352 352 m = submit_email('message_reply_by_subject.eml')
353 353 assert m.is_a?(Message)
354 354 assert !m.new_record?
355 355 m.reload
356 356 assert_equal 'Reply to the first post', m.subject
357 357 assert_equal Message.find(1), m.parent
358 358 end
359 359
360 360 def test_should_strip_tags_of_html_only_emails
361 361 issue = submit_email('ticket_html_only.eml', :issue => {:project => 'ecookbook'})
362 362 assert issue.is_a?(Issue)
363 363 assert !issue.new_record?
364 364 issue.reload
365 365 assert_equal 'HTML email', issue.subject
366 366 assert_equal 'This is a html-only email.', issue.description
367 367 end
368 368
369 369 context "truncate emails based on the Setting" do
370 370 context "with no setting" do
371 371 setup do
372 372 Setting.mail_handler_body_delimiters = ''
373 373 end
374 374
375 375 should "add the entire email into the issue" do
376 376 issue = submit_email('ticket_on_given_project.eml')
377 377 assert_issue_created(issue)
378 378 assert issue.description.include?('---')
379 379 assert issue.description.include?('This paragraph is after the delimiter')
380 380 end
381 381 end
382 382
383 383 context "with a single string" do
384 384 setup do
385 385 Setting.mail_handler_body_delimiters = '---'
386 386 end
387 387 should "truncate the email at the delimiter for the issue" do
388 388 issue = submit_email('ticket_on_given_project.eml')
389 389 assert_issue_created(issue)
390 390 assert issue.description.include?('This paragraph is before delimiters')
391 391 assert issue.description.include?('--- This line starts with a delimiter')
392 392 assert !issue.description.match(/^---$/)
393 393 assert !issue.description.include?('This paragraph is after the delimiter')
394 394 end
395 395 end
396 396
397 397 context "with a single quoted reply (e.g. reply to a Redmine email notification)" do
398 398 setup do
399 399 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
400 400 end
401 401 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
402 402 journal = submit_email('issue_update_with_quoted_reply_above.eml')
403 403 assert journal.is_a?(Journal)
404 404 assert journal.notes.include?('An update to the issue by the sender.')
405 405 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
406 406 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
407 407 end
408 408 end
409 409
410 410 context "with multiple quoted replies (e.g. reply to a reply of a Redmine email notification)" do
411 411 setup do
412 412 Setting.mail_handler_body_delimiters = '--- Reply above. Do not remove this line. ---'
413 413 end
414 414 should "truncate the email at the delimiter with the quoted reply symbols (>)" do
415 415 journal = submit_email('issue_update_with_multiple_quoted_reply_above.eml')
416 416 assert journal.is_a?(Journal)
417 417 assert journal.notes.include?('An update to the issue by the sender.')
418 418 assert !journal.notes.match(Regexp.escape("--- Reply above. Do not remove this line. ---"))
419 419 assert !journal.notes.include?('Looks like the JSON api for projects was missed.')
420 420 end
421 421 end
422 422
423 423 context "with multiple strings" do
424 424 setup do
425 425 Setting.mail_handler_body_delimiters = "---\nBREAK"
426 426 end
427 427 should "truncate the email at the first delimiter found (BREAK)" do
428 428 issue = submit_email('ticket_on_given_project.eml')
429 429 assert_issue_created(issue)
430 430 assert issue.description.include?('This paragraph is before delimiters')
431 431 assert !issue.description.include?('BREAK')
432 432 assert !issue.description.include?('This paragraph is between delimiters')
433 433 assert !issue.description.match(/^---$/)
434 434 assert !issue.description.include?('This paragraph is after the delimiter')
435 435 end
436 436 end
437 437 end
438 438
439 439 def test_email_with_long_subject_line
440 440 issue = submit_email('ticket_with_long_subject.eml')
441 441 assert issue.is_a?(Issue)
442 442 assert_equal issue.subject, 'New ticket on a given project with a very long subject line which exceeds 255 chars and should not be ignored but chopped off. And if the subject line is still not long enough, we just add more text. And more text. Wow, this is really annoying. Especially, if you have nothing to say...'[0,255]
443 443 end
444 444
445 445 private
446 446
447 447 def submit_email(filename, options={})
448 448 raw = IO.read(File.join(FIXTURES_PATH, filename))
449 449 MailHandler.receive(raw, options)
450 450 end
451 451
452 452 def assert_issue_created(issue)
453 453 assert issue.is_a?(Issue)
454 454 assert !issue.new_record?
455 455 issue.reload
456 456 end
457 457 end
General Comments 0
You need to be logged in to leave comments. Login now