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