##// END OF EJS Templates
Remove the limitation on characters that can be used in custom_field, issue_status, role, tracker, user names (#5152)....
Jean-Philippe Lang -
r4479:44ffc5a3365f
parent child
Show More
@@ -1,121 +1,120
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class CustomField < ActiveRecord::Base
19 19 has_many :custom_values, :dependent => :delete_all
20 20 acts_as_list :scope => 'type = \'#{self.class}\''
21 21 serialize :possible_values
22 22
23 23 validates_presence_of :name, :field_format
24 24 validates_uniqueness_of :name, :scope => :type
25 25 validates_length_of :name, :maximum => 30
26 validates_format_of :name, :with => /^[\w\s\.\'\-]*$/i
27 26 validates_inclusion_of :field_format, :in => Redmine::CustomFieldFormat.available_formats
28 27
29 28 def initialize(attributes = nil)
30 29 super
31 30 self.possible_values ||= []
32 31 end
33 32
34 33 def before_validation
35 34 # make sure these fields are not searchable
36 35 self.searchable = false if %w(int float date bool).include?(field_format)
37 36 true
38 37 end
39 38
40 39 def validate
41 40 if self.field_format == "list"
42 41 errors.add(:possible_values, :blank) if self.possible_values.nil? || self.possible_values.empty?
43 42 errors.add(:possible_values, :invalid) unless self.possible_values.is_a? Array
44 43 end
45 44
46 45 # validate default value
47 46 v = CustomValue.new(:custom_field => self.clone, :value => default_value, :customized => nil)
48 47 v.custom_field.is_required = false
49 48 errors.add(:default_value, :invalid) unless v.valid?
50 49 end
51 50
52 51 # Makes possible_values accept a multiline string
53 52 def possible_values=(arg)
54 53 if arg.is_a?(Array)
55 54 write_attribute(:possible_values, arg.compact.collect(&:strip).select {|v| !v.blank?})
56 55 else
57 56 self.possible_values = arg.to_s.split(/[\n\r]+/)
58 57 end
59 58 end
60 59
61 60 def cast_value(value)
62 61 casted = nil
63 62 unless value.blank?
64 63 case field_format
65 64 when 'string', 'text', 'list'
66 65 casted = value
67 66 when 'date'
68 67 casted = begin; value.to_date; rescue; nil end
69 68 when 'bool'
70 69 casted = (value == '1' ? true : false)
71 70 when 'int'
72 71 casted = value.to_i
73 72 when 'float'
74 73 casted = value.to_f
75 74 end
76 75 end
77 76 casted
78 77 end
79 78
80 79 # Returns a ORDER BY clause that can used to sort customized
81 80 # objects by their value of the custom field.
82 81 # Returns false, if the custom field can not be used for sorting.
83 82 def order_statement
84 83 case field_format
85 84 when 'string', 'text', 'list', 'date', 'bool'
86 85 # COALESCE is here to make sure that blank and NULL values are sorted equally
87 86 "COALESCE((SELECT cv_sort.value FROM #{CustomValue.table_name} cv_sort" +
88 87 " WHERE cv_sort.customized_type='#{self.class.customized_class.name}'" +
89 88 " AND cv_sort.customized_id=#{self.class.customized_class.table_name}.id" +
90 89 " AND cv_sort.custom_field_id=#{id} LIMIT 1), '')"
91 90 when 'int', 'float'
92 91 # Make the database cast values into numeric
93 92 # Postgresql will raise an error if a value can not be casted!
94 93 # CustomValue validations should ensure that it doesn't occur
95 94 "(SELECT CAST(cv_sort.value AS decimal(60,3)) FROM #{CustomValue.table_name} cv_sort" +
96 95 " WHERE cv_sort.customized_type='#{self.class.customized_class.name}'" +
97 96 " AND cv_sort.customized_id=#{self.class.customized_class.table_name}.id" +
98 97 " AND cv_sort.custom_field_id=#{id} AND cv_sort.value <> '' AND cv_sort.value IS NOT NULL LIMIT 1)"
99 98 else
100 99 nil
101 100 end
102 101 end
103 102
104 103 def <=>(field)
105 104 position <=> field.position
106 105 end
107 106
108 107 def self.customized_class
109 108 self.name =~ /^(.+)CustomField$/
110 109 begin; $1.constantize; rescue nil; end
111 110 end
112 111
113 112 # to move in project_custom_field
114 113 def self.for_all
115 114 find(:all, :conditions => ["is_for_all=?", true], :order => 'position')
116 115 end
117 116
118 117 def type_name
119 118 nil
120 119 end
121 120 end
@@ -1,99 +1,98
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueStatus < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 20 has_many :workflows, :foreign_key => "old_status_id"
21 21 acts_as_list
22 22
23 23 before_destroy :delete_workflows
24 24
25 25 validates_presence_of :name
26 26 validates_uniqueness_of :name
27 27 validates_length_of :name, :maximum => 30
28 validates_format_of :name, :with => /^[\w\s\'\-]*$/i
29 28 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
30 29
31 30 def after_save
32 31 IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default?
33 32 end
34 33
35 34 # Returns the default status for new issues
36 35 def self.default
37 36 find(:first, :conditions =>["is_default=?", true])
38 37 end
39 38
40 39 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
41 40 def self.update_issue_done_ratios
42 41 if Issue.use_status_for_done_ratio?
43 42 IssueStatus.find(:all, :conditions => ["default_done_ratio >= 0"]).each do |status|
44 43 Issue.update_all(["done_ratio = ?", status.default_done_ratio],
45 44 ["status_id = ?", status.id])
46 45 end
47 46 end
48 47
49 48 return Issue.use_status_for_done_ratio?
50 49 end
51 50
52 51 # Returns an array of all statuses the given role can switch to
53 52 # Uses association cache when called more than one time
54 53 def new_statuses_allowed_to(roles, tracker)
55 54 if roles && tracker
56 55 role_ids = roles.collect(&:id)
57 56 new_statuses = workflows.select {|w| role_ids.include?(w.role_id) && w.tracker_id == tracker.id}.collect{|w| w.new_status}.compact.sort
58 57 else
59 58 []
60 59 end
61 60 end
62 61
63 62 # Same thing as above but uses a database query
64 63 # More efficient than the previous method if called just once
65 64 def find_new_statuses_allowed_to(roles, tracker)
66 65 if roles && tracker
67 66 workflows.find(:all,
68 67 :include => :new_status,
69 68 :conditions => { :role_id => roles.collect(&:id),
70 69 :tracker_id => tracker.id}).collect{ |w| w.new_status }.compact.sort
71 70 else
72 71 []
73 72 end
74 73 end
75 74
76 75 def new_status_allowed_to?(status, roles, tracker)
77 76 if status && roles && tracker
78 77 !workflows.find(:first, :conditions => {:new_status_id => status.id, :role_id => roles.collect(&:id), :tracker_id => tracker.id}).nil?
79 78 else
80 79 false
81 80 end
82 81 end
83 82
84 83 def <=>(status)
85 84 position <=> status.position
86 85 end
87 86
88 87 def to_s; name end
89 88
90 89 private
91 90 def check_integrity
92 91 raise "Can't delete status" if Issue.find(:first, :conditions => ["status_id=?", self.id])
93 92 end
94 93
95 94 # Deletes associated workflows
96 95 def delete_workflows
97 96 Workflow.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
98 97 end
99 98 end
@@ -1,163 +1,162
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Role < ActiveRecord::Base
19 19 # Built-in roles
20 20 BUILTIN_NON_MEMBER = 1
21 21 BUILTIN_ANONYMOUS = 2
22 22
23 23 named_scope :givable, { :conditions => "builtin = 0", :order => 'position' }
24 24 named_scope :builtin, lambda { |*args|
25 25 compare = 'not' if args.first == true
26 26 { :conditions => "#{compare} builtin = 0" }
27 27 }
28 28
29 29 before_destroy :check_deletable
30 30 has_many :workflows, :dependent => :delete_all do
31 31 def copy(source_role)
32 32 Workflow.copy(nil, source_role, nil, proxy_owner)
33 33 end
34 34 end
35 35
36 36 has_many :member_roles, :dependent => :destroy
37 37 has_many :members, :through => :member_roles
38 38 acts_as_list
39 39
40 40 serialize :permissions, Array
41 41 attr_protected :builtin
42 42
43 43 validates_presence_of :name
44 44 validates_uniqueness_of :name
45 45 validates_length_of :name, :maximum => 30
46 validates_format_of :name, :with => /^[\w\s\'\-]*$/i
47 46
48 47 def permissions
49 48 read_attribute(:permissions) || []
50 49 end
51 50
52 51 def permissions=(perms)
53 52 perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
54 53 write_attribute(:permissions, perms)
55 54 end
56 55
57 56 def add_permission!(*perms)
58 57 self.permissions = [] unless permissions.is_a?(Array)
59 58
60 59 permissions_will_change!
61 60 perms.each do |p|
62 61 p = p.to_sym
63 62 permissions << p unless permissions.include?(p)
64 63 end
65 64 save!
66 65 end
67 66
68 67 def remove_permission!(*perms)
69 68 return unless permissions.is_a?(Array)
70 69 permissions_will_change!
71 70 perms.each { |p| permissions.delete(p.to_sym) }
72 71 save!
73 72 end
74 73
75 74 # Returns true if the role has the given permission
76 75 def has_permission?(perm)
77 76 !permissions.nil? && permissions.include?(perm.to_sym)
78 77 end
79 78
80 79 def <=>(role)
81 80 role ? position <=> role.position : -1
82 81 end
83 82
84 83 def to_s
85 84 name
86 85 end
87 86
88 87 # Return true if the role is a builtin role
89 88 def builtin?
90 89 self.builtin != 0
91 90 end
92 91
93 92 # Return true if the role is a project member role
94 93 def member?
95 94 !self.builtin?
96 95 end
97 96
98 97 # Return true if role is allowed to do the specified action
99 98 # action can be:
100 99 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
101 100 # * a permission Symbol (eg. :edit_project)
102 101 def allowed_to?(action)
103 102 if action.is_a? Hash
104 103 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
105 104 else
106 105 allowed_permissions.include? action
107 106 end
108 107 end
109 108
110 109 # Return all the permissions that can be given to the role
111 110 def setable_permissions
112 111 setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
113 112 setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
114 113 setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
115 114 setable_permissions
116 115 end
117 116
118 117 # Find all the roles that can be given to a project member
119 118 def self.find_all_givable
120 119 find(:all, :conditions => {:builtin => 0}, :order => 'position')
121 120 end
122 121
123 122 # Return the builtin 'non member' role. If the role doesn't exist,
124 123 # it will be created on the fly.
125 124 def self.non_member
126 125 non_member_role = find(:first, :conditions => {:builtin => BUILTIN_NON_MEMBER})
127 126 if non_member_role.nil?
128 127 non_member_role = create(:name => 'Non member', :position => 0) do |role|
129 128 role.builtin = BUILTIN_NON_MEMBER
130 129 end
131 130 raise 'Unable to create the non-member role.' if non_member_role.new_record?
132 131 end
133 132 non_member_role
134 133 end
135 134
136 135 # Return the builtin 'anonymous' role. If the role doesn't exist,
137 136 # it will be created on the fly.
138 137 def self.anonymous
139 138 anonymous_role = find(:first, :conditions => {:builtin => BUILTIN_ANONYMOUS})
140 139 if anonymous_role.nil?
141 140 anonymous_role = create(:name => 'Anonymous', :position => 0) do |role|
142 141 role.builtin = BUILTIN_ANONYMOUS
143 142 end
144 143 raise 'Unable to create the anonymous role.' if anonymous_role.new_record?
145 144 end
146 145 anonymous_role
147 146 end
148 147
149 148
150 149 private
151 150 def allowed_permissions
152 151 @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
153 152 end
154 153
155 154 def allowed_actions
156 155 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
157 156 end
158 157
159 158 def check_deletable
160 159 raise "Can't delete role" if members.any?
161 160 raise "Can't delete builtin role" if builtin?
162 161 end
163 162 end
@@ -1,67 +1,66
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Tracker < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 20 has_many :issues
21 21 has_many :workflows, :dependent => :delete_all do
22 22 def copy(source_tracker)
23 23 Workflow.copy(source_tracker, nil, proxy_owner, nil)
24 24 end
25 25 end
26 26
27 27 has_and_belongs_to_many :projects
28 28 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
29 29 acts_as_list
30 30
31 31 validates_presence_of :name
32 32 validates_uniqueness_of :name
33 33 validates_length_of :name, :maximum => 30
34 validates_format_of :name, :with => /^[\w\s\'\-]*$/i
35 34
36 35 def to_s; name end
37 36
38 37 def <=>(tracker)
39 38 name <=> tracker.name
40 39 end
41 40
42 41 def self.all
43 42 find(:all, :order => 'position')
44 43 end
45 44
46 45 # Returns an array of IssueStatus that are used
47 46 # in the tracker's workflows
48 47 def issue_statuses
49 48 if @issue_statuses
50 49 return @issue_statuses
51 50 elsif new_record?
52 51 return []
53 52 end
54 53
55 54 ids = Workflow.
56 55 connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{Workflow.table_name} WHERE tracker_id = #{id}").
57 56 flatten.
58 57 uniq
59 58
60 59 @issue_statuses = IssueStatus.find_all_by_id(ids).sort
61 60 end
62 61
63 62 private
64 63 def check_integrity
65 64 raise "Can't delete tracker" if Issue.find(:first, :conditions => ["tracker_id=?", self.id])
66 65 end
67 66 end
@@ -1,502 +1,501
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2009 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Account statuses
24 24 STATUS_ANONYMOUS = 0
25 25 STATUS_ACTIVE = 1
26 26 STATUS_REGISTERED = 2
27 27 STATUS_LOCKED = 3
28 28
29 29 USER_FORMATS = {
30 30 :firstname_lastname => '#{firstname} #{lastname}',
31 31 :firstname => '#{firstname}',
32 32 :lastname_firstname => '#{lastname} #{firstname}',
33 33 :lastname_coma_firstname => '#{lastname}, #{firstname}',
34 34 :username => '#{login}'
35 35 }
36 36
37 37 MAIL_NOTIFICATION_OPTIONS = [
38 38 ['all', :label_user_mail_option_all],
39 39 ['selected', :label_user_mail_option_selected],
40 40 ['only_my_events', :label_user_mail_option_only_my_events],
41 41 ['only_assigned', :label_user_mail_option_only_assigned],
42 42 ['only_owner', :label_user_mail_option_only_owner],
43 43 ['none', :label_user_mail_option_none]
44 44 ]
45 45
46 46 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
47 47 :after_remove => Proc.new {|user, group| group.user_removed(user)}
48 48 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
49 49 has_many :changesets, :dependent => :nullify
50 50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
51 51 has_one :rss_token, :dependent => :destroy, :class_name => 'Token', :conditions => "action='feeds'"
52 52 has_one :api_token, :dependent => :destroy, :class_name => 'Token', :conditions => "action='api'"
53 53 belongs_to :auth_source
54 54
55 55 # Active non-anonymous users scope
56 56 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
57 57
58 58 acts_as_customizable
59 59
60 60 attr_accessor :password, :password_confirmation
61 61 attr_accessor :last_before_login_on
62 62 # Prevents unauthorized assignments
63 63 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
64 64
65 65 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
66 66 validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }, :case_sensitive => false
67 67 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
68 68 # Login must contain lettres, numbers, underscores only
69 69 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
70 70 validates_length_of :login, :maximum => 30
71 validates_format_of :firstname, :lastname, :with => /^[\w\s\'\-\.]*$/i
72 71 validates_length_of :firstname, :lastname, :maximum => 30
73 72 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_nil => true
74 73 validates_length_of :mail, :maximum => 60, :allow_nil => true
75 74 validates_confirmation_of :password, :allow_nil => true
76 75 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
77 76
78 77 def before_create
79 78 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
80 79 true
81 80 end
82 81
83 82 def before_save
84 83 # update hashed_password if password was set
85 84 self.hashed_password = User.hash_password(self.password) if self.password && self.auth_source_id.blank?
86 85 end
87 86
88 87 def reload(*args)
89 88 @name = nil
90 89 super
91 90 end
92 91
93 92 def mail=(arg)
94 93 write_attribute(:mail, arg.to_s.strip)
95 94 end
96 95
97 96 def identity_url=(url)
98 97 if url.blank?
99 98 write_attribute(:identity_url, '')
100 99 else
101 100 begin
102 101 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
103 102 rescue OpenIdAuthentication::InvalidOpenId
104 103 # Invlaid url, don't save
105 104 end
106 105 end
107 106 self.read_attribute(:identity_url)
108 107 end
109 108
110 109 # Returns the user that matches provided login and password, or nil
111 110 def self.try_to_login(login, password)
112 111 # Make sure no one can sign in with an empty password
113 112 return nil if password.to_s.empty?
114 113 user = find_by_login(login)
115 114 if user
116 115 # user is already in local database
117 116 return nil if !user.active?
118 117 if user.auth_source
119 118 # user has an external authentication method
120 119 return nil unless user.auth_source.authenticate(login, password)
121 120 else
122 121 # authentication with local password
123 122 return nil unless User.hash_password(password) == user.hashed_password
124 123 end
125 124 else
126 125 # user is not yet registered, try to authenticate with available sources
127 126 attrs = AuthSource.authenticate(login, password)
128 127 if attrs
129 128 user = new(attrs)
130 129 user.login = login
131 130 user.language = Setting.default_language
132 131 if user.save
133 132 user.reload
134 133 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
135 134 end
136 135 end
137 136 end
138 137 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
139 138 user
140 139 rescue => text
141 140 raise text
142 141 end
143 142
144 143 # Returns the user who matches the given autologin +key+ or nil
145 144 def self.try_to_autologin(key)
146 145 tokens = Token.find_all_by_action_and_value('autologin', key)
147 146 # Make sure there's only 1 token that matches the key
148 147 if tokens.size == 1
149 148 token = tokens.first
150 149 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
151 150 token.user.update_attribute(:last_login_on, Time.now)
152 151 token.user
153 152 end
154 153 end
155 154 end
156 155
157 156 # Return user's full name for display
158 157 def name(formatter = nil)
159 158 if formatter
160 159 eval('"' + (USER_FORMATS[formatter] || USER_FORMATS[:firstname_lastname]) + '"')
161 160 else
162 161 @name ||= eval('"' + (USER_FORMATS[Setting.user_format] || USER_FORMATS[:firstname_lastname]) + '"')
163 162 end
164 163 end
165 164
166 165 def active?
167 166 self.status == STATUS_ACTIVE
168 167 end
169 168
170 169 def registered?
171 170 self.status == STATUS_REGISTERED
172 171 end
173 172
174 173 def locked?
175 174 self.status == STATUS_LOCKED
176 175 end
177 176
178 177 def activate
179 178 self.status = STATUS_ACTIVE
180 179 end
181 180
182 181 def register
183 182 self.status = STATUS_REGISTERED
184 183 end
185 184
186 185 def lock
187 186 self.status = STATUS_LOCKED
188 187 end
189 188
190 189 def activate!
191 190 update_attribute(:status, STATUS_ACTIVE)
192 191 end
193 192
194 193 def register!
195 194 update_attribute(:status, STATUS_REGISTERED)
196 195 end
197 196
198 197 def lock!
199 198 update_attribute(:status, STATUS_LOCKED)
200 199 end
201 200
202 201 def check_password?(clear_password)
203 202 if auth_source_id.present?
204 203 auth_source.authenticate(self.login, clear_password)
205 204 else
206 205 User.hash_password(clear_password) == self.hashed_password
207 206 end
208 207 end
209 208
210 209 # Does the backend storage allow this user to change their password?
211 210 def change_password_allowed?
212 211 return true if auth_source_id.blank?
213 212 return auth_source.allow_password_changes?
214 213 end
215 214
216 215 # Generate and set a random password. Useful for automated user creation
217 216 # Based on Token#generate_token_value
218 217 #
219 218 def random_password
220 219 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
221 220 password = ''
222 221 40.times { |i| password << chars[rand(chars.size-1)] }
223 222 self.password = password
224 223 self.password_confirmation = password
225 224 self
226 225 end
227 226
228 227 def pref
229 228 self.preference ||= UserPreference.new(:user => self)
230 229 end
231 230
232 231 def time_zone
233 232 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
234 233 end
235 234
236 235 def wants_comments_in_reverse_order?
237 236 self.pref[:comments_sorting] == 'desc'
238 237 end
239 238
240 239 # Return user's RSS key (a 40 chars long string), used to access feeds
241 240 def rss_key
242 241 token = self.rss_token || Token.create(:user => self, :action => 'feeds')
243 242 token.value
244 243 end
245 244
246 245 # Return user's API key (a 40 chars long string), used to access the API
247 246 def api_key
248 247 token = self.api_token || self.create_api_token(:action => 'api')
249 248 token.value
250 249 end
251 250
252 251 # Return an array of project ids for which the user has explicitly turned mail notifications on
253 252 def notified_projects_ids
254 253 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
255 254 end
256 255
257 256 def notified_project_ids=(ids)
258 257 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
259 258 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
260 259 @notified_projects_ids = nil
261 260 notified_projects_ids
262 261 end
263 262
264 263 # Only users that belong to more than 1 project can select projects for which they are notified
265 264 def valid_notification_options
266 265 # Note that @user.membership.size would fail since AR ignores
267 266 # :include association option when doing a count
268 267 if memberships.length < 1
269 268 MAIL_NOTIFICATION_OPTIONS.delete_if {|option| option.first == 'selected'}
270 269 else
271 270 MAIL_NOTIFICATION_OPTIONS
272 271 end
273 272 end
274 273
275 274 # Find a user account by matching the exact login and then a case-insensitive
276 275 # version. Exact matches will be given priority.
277 276 def self.find_by_login(login)
278 277 # force string comparison to be case sensitive on MySQL
279 278 type_cast = (ActiveRecord::Base.connection.adapter_name == 'MySQL') ? 'BINARY' : ''
280 279
281 280 # First look for an exact match
282 281 user = first(:conditions => ["#{type_cast} login = ?", login])
283 282 # Fail over to case-insensitive if none was found
284 283 user ||= first(:conditions => ["#{type_cast} LOWER(login) = ?", login.to_s.downcase])
285 284 end
286 285
287 286 def self.find_by_rss_key(key)
288 287 token = Token.find_by_value(key)
289 288 token && token.user.active? ? token.user : nil
290 289 end
291 290
292 291 def self.find_by_api_key(key)
293 292 token = Token.find_by_action_and_value('api', key)
294 293 token && token.user.active? ? token.user : nil
295 294 end
296 295
297 296 # Makes find_by_mail case-insensitive
298 297 def self.find_by_mail(mail)
299 298 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
300 299 end
301 300
302 301 def to_s
303 302 name
304 303 end
305 304
306 305 # Returns the current day according to user's time zone
307 306 def today
308 307 if time_zone.nil?
309 308 Date.today
310 309 else
311 310 Time.now.in_time_zone(time_zone).to_date
312 311 end
313 312 end
314 313
315 314 def logged?
316 315 true
317 316 end
318 317
319 318 def anonymous?
320 319 !logged?
321 320 end
322 321
323 322 # Return user's roles for project
324 323 def roles_for_project(project)
325 324 roles = []
326 325 # No role on archived projects
327 326 return roles unless project && project.active?
328 327 if logged?
329 328 # Find project membership
330 329 membership = memberships.detect {|m| m.project_id == project.id}
331 330 if membership
332 331 roles = membership.roles
333 332 else
334 333 @role_non_member ||= Role.non_member
335 334 roles << @role_non_member
336 335 end
337 336 else
338 337 @role_anonymous ||= Role.anonymous
339 338 roles << @role_anonymous
340 339 end
341 340 roles
342 341 end
343 342
344 343 # Return true if the user is a member of project
345 344 def member_of?(project)
346 345 !roles_for_project(project).detect {|role| role.member?}.nil?
347 346 end
348 347
349 348 # Return true if the user is allowed to do the specified action on a specific context
350 349 # Action can be:
351 350 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
352 351 # * a permission Symbol (eg. :edit_project)
353 352 # Context can be:
354 353 # * a project : returns true if user is allowed to do the specified action on this project
355 354 # * a group of projects : returns true if user is allowed on every project
356 355 # * nil with options[:global] set : check if user has at least one role allowed for this action,
357 356 # or falls back to Non Member / Anonymous permissions depending if the user is logged
358 357 def allowed_to?(action, context, options={})
359 358 if context && context.is_a?(Project)
360 359 # No action allowed on archived projects
361 360 return false unless context.active?
362 361 # No action allowed on disabled modules
363 362 return false unless context.allows_to?(action)
364 363 # Admin users are authorized for anything else
365 364 return true if admin?
366 365
367 366 roles = roles_for_project(context)
368 367 return false unless roles
369 368 roles.detect {|role| (context.is_public? || role.member?) && role.allowed_to?(action)}
370 369
371 370 elsif context && context.is_a?(Array)
372 371 # Authorize if user is authorized on every element of the array
373 372 context.map do |project|
374 373 allowed_to?(action,project,options)
375 374 end.inject do |memo,allowed|
376 375 memo && allowed
377 376 end
378 377 elsif options[:global]
379 378 # Admin users are always authorized
380 379 return true if admin?
381 380
382 381 # authorize if user has at least one role that has this permission
383 382 roles = memberships.collect {|m| m.roles}.flatten.uniq
384 383 roles.detect {|r| r.allowed_to?(action)} || (self.logged? ? Role.non_member.allowed_to?(action) : Role.anonymous.allowed_to?(action))
385 384 else
386 385 false
387 386 end
388 387 end
389 388
390 389 # Is the user allowed to do the specified action on any project?
391 390 # See allowed_to? for the actions and valid options.
392 391 def allowed_to_globally?(action, options)
393 392 allowed_to?(action, nil, options.reverse_merge(:global => true))
394 393 end
395 394
396 395 safe_attributes 'login',
397 396 'firstname',
398 397 'lastname',
399 398 'mail',
400 399 'mail_notification',
401 400 'language',
402 401 'custom_field_values',
403 402 'custom_fields',
404 403 'identity_url'
405 404
406 405 safe_attributes 'status',
407 406 'auth_source_id',
408 407 :if => lambda {|user, current_user| current_user.admin?}
409 408
410 409 safe_attributes 'group_ids',
411 410 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
412 411
413 412 # Utility method to help check if a user should be notified about an
414 413 # event.
415 414 #
416 415 # TODO: only supports Issue events currently
417 416 def notify_about?(object)
418 417 case mail_notification
419 418 when 'all'
420 419 true
421 420 when 'selected'
422 421 # Handled by the Project
423 422 when 'none'
424 423 false
425 424 when 'only_my_events'
426 425 if object.is_a?(Issue) && (object.author == self || object.assigned_to == self)
427 426 true
428 427 else
429 428 false
430 429 end
431 430 when 'only_assigned'
432 431 if object.is_a?(Issue) && object.assigned_to == self
433 432 true
434 433 else
435 434 false
436 435 end
437 436 when 'only_owner'
438 437 if object.is_a?(Issue) && object.author == self
439 438 true
440 439 else
441 440 false
442 441 end
443 442 else
444 443 false
445 444 end
446 445 end
447 446
448 447 def self.current=(user)
449 448 @current_user = user
450 449 end
451 450
452 451 def self.current
453 452 @current_user ||= User.anonymous
454 453 end
455 454
456 455 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
457 456 # one anonymous user per database.
458 457 def self.anonymous
459 458 anonymous_user = AnonymousUser.find(:first)
460 459 if anonymous_user.nil?
461 460 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
462 461 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
463 462 end
464 463 anonymous_user
465 464 end
466 465
467 466 protected
468 467
469 468 def validate
470 469 # Password length validation based on setting
471 470 if !password.nil? && password.size < Setting.password_min_length.to_i
472 471 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
473 472 end
474 473 end
475 474
476 475 private
477 476
478 477 # Return password digest
479 478 def self.hash_password(clear_password)
480 479 Digest::SHA1.hexdigest(clear_password || "")
481 480 end
482 481 end
483 482
484 483 class AnonymousUser < User
485 484
486 485 def validate_on_create
487 486 # There should be only one AnonymousUser in the database
488 487 errors.add_to_base 'An anonymous user already exists.' if AnonymousUser.find(:first)
489 488 end
490 489
491 490 def available_custom_fields
492 491 []
493 492 end
494 493
495 494 # Overrides a few properties
496 495 def logged?; false end
497 496 def admin; false end
498 497 def name(*args); I18n.t(:label_user_anonymous) end
499 498 def mail; nil end
500 499 def time_zone; nil end
501 500 def rss_key; nil end
502 501 end
@@ -1,514 +1,512
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 desc 'Mantis migration script'
19 19
20 20 require 'active_record'
21 21 require 'iconv'
22 22 require 'pp'
23 23
24 24 namespace :redmine do
25 25 task :migrate_from_mantis => :environment do
26 26
27 27 module MantisMigrate
28 28
29 29 DEFAULT_STATUS = IssueStatus.default
30 30 assigned_status = IssueStatus.find_by_position(2)
31 31 resolved_status = IssueStatus.find_by_position(3)
32 32 feedback_status = IssueStatus.find_by_position(4)
33 33 closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
34 34 STATUS_MAPPING = {10 => DEFAULT_STATUS, # new
35 35 20 => feedback_status, # feedback
36 36 30 => DEFAULT_STATUS, # acknowledged
37 37 40 => DEFAULT_STATUS, # confirmed
38 38 50 => assigned_status, # assigned
39 39 80 => resolved_status, # resolved
40 40 90 => closed_status # closed
41 41 }
42 42
43 43 priorities = IssuePriority.all
44 44 DEFAULT_PRIORITY = priorities[2]
45 45 PRIORITY_MAPPING = {10 => priorities[1], # none
46 46 20 => priorities[1], # low
47 47 30 => priorities[2], # normal
48 48 40 => priorities[3], # high
49 49 50 => priorities[4], # urgent
50 50 60 => priorities[5] # immediate
51 51 }
52 52
53 53 TRACKER_BUG = Tracker.find_by_position(1)
54 54 TRACKER_FEATURE = Tracker.find_by_position(2)
55 55
56 56 roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
57 57 manager_role = roles[0]
58 58 developer_role = roles[1]
59 59 DEFAULT_ROLE = roles.last
60 60 ROLE_MAPPING = {10 => DEFAULT_ROLE, # viewer
61 61 25 => DEFAULT_ROLE, # reporter
62 62 40 => DEFAULT_ROLE, # updater
63 63 55 => developer_role, # developer
64 64 70 => manager_role, # manager
65 65 90 => manager_role # administrator
66 66 }
67 67
68 68 CUSTOM_FIELD_TYPE_MAPPING = {0 => 'string', # String
69 69 1 => 'int', # Numeric
70 70 2 => 'int', # Float
71 71 3 => 'list', # Enumeration
72 72 4 => 'string', # Email
73 73 5 => 'bool', # Checkbox
74 74 6 => 'list', # List
75 75 7 => 'list', # Multiselection list
76 76 8 => 'date', # Date
77 77 }
78 78
79 79 RELATION_TYPE_MAPPING = {1 => IssueRelation::TYPE_RELATES, # related to
80 80 2 => IssueRelation::TYPE_RELATES, # parent of
81 81 3 => IssueRelation::TYPE_RELATES, # child of
82 82 0 => IssueRelation::TYPE_DUPLICATES, # duplicate of
83 83 4 => IssueRelation::TYPE_DUPLICATES # has duplicate
84 84 }
85 85
86 86 class MantisUser < ActiveRecord::Base
87 87 set_table_name :mantis_user_table
88 88
89 89 def firstname
90 90 @firstname = realname.blank? ? username : realname.split.first[0..29]
91 @firstname.gsub!(/[^\w\s\'\-]/i, '')
92 91 @firstname
93 92 end
94 93
95 94 def lastname
96 95 @lastname = realname.blank? ? '-' : realname.split[1..-1].join(' ')[0..29]
97 @lastname.gsub!(/[^\w\s\'\-]/i, '')
98 96 @lastname = '-' if @lastname.blank?
99 97 @lastname
100 98 end
101 99
102 100 def email
103 101 if read_attribute(:email).match(/^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i) &&
104 102 !User.find_by_mail(read_attribute(:email))
105 103 @email = read_attribute(:email)
106 104 else
107 105 @email = "#{username}@foo.bar"
108 106 end
109 107 end
110 108
111 109 def username
112 110 read_attribute(:username)[0..29].gsub(/[^a-zA-Z0-9_\-@\.]/, '-')
113 111 end
114 112 end
115 113
116 114 class MantisProject < ActiveRecord::Base
117 115 set_table_name :mantis_project_table
118 116 has_many :versions, :class_name => "MantisVersion", :foreign_key => :project_id
119 117 has_many :categories, :class_name => "MantisCategory", :foreign_key => :project_id
120 118 has_many :news, :class_name => "MantisNews", :foreign_key => :project_id
121 119 has_many :members, :class_name => "MantisProjectUser", :foreign_key => :project_id
122 120
123 121 def identifier
124 122 read_attribute(:name).gsub(/[^a-z0-9\-]+/, '-').slice(0, Project::IDENTIFIER_MAX_LENGTH)
125 123 end
126 124 end
127 125
128 126 class MantisVersion < ActiveRecord::Base
129 127 set_table_name :mantis_project_version_table
130 128
131 129 def version
132 130 read_attribute(:version)[0..29]
133 131 end
134 132
135 133 def description
136 134 read_attribute(:description)[0..254]
137 135 end
138 136 end
139 137
140 138 class MantisCategory < ActiveRecord::Base
141 139 set_table_name :mantis_project_category_table
142 140 end
143 141
144 142 class MantisProjectUser < ActiveRecord::Base
145 143 set_table_name :mantis_project_user_list_table
146 144 end
147 145
148 146 class MantisBug < ActiveRecord::Base
149 147 set_table_name :mantis_bug_table
150 148 belongs_to :bug_text, :class_name => "MantisBugText", :foreign_key => :bug_text_id
151 149 has_many :bug_notes, :class_name => "MantisBugNote", :foreign_key => :bug_id
152 150 has_many :bug_files, :class_name => "MantisBugFile", :foreign_key => :bug_id
153 151 has_many :bug_monitors, :class_name => "MantisBugMonitor", :foreign_key => :bug_id
154 152 end
155 153
156 154 class MantisBugText < ActiveRecord::Base
157 155 set_table_name :mantis_bug_text_table
158 156
159 157 # Adds Mantis steps_to_reproduce and additional_information fields
160 158 # to description if any
161 159 def full_description
162 160 full_description = description
163 161 full_description += "\n\n*Steps to reproduce:*\n\n#{steps_to_reproduce}" unless steps_to_reproduce.blank?
164 162 full_description += "\n\n*Additional information:*\n\n#{additional_information}" unless additional_information.blank?
165 163 full_description
166 164 end
167 165 end
168 166
169 167 class MantisBugNote < ActiveRecord::Base
170 168 set_table_name :mantis_bugnote_table
171 169 belongs_to :bug, :class_name => "MantisBug", :foreign_key => :bug_id
172 170 belongs_to :bug_note_text, :class_name => "MantisBugNoteText", :foreign_key => :bugnote_text_id
173 171 end
174 172
175 173 class MantisBugNoteText < ActiveRecord::Base
176 174 set_table_name :mantis_bugnote_text_table
177 175 end
178 176
179 177 class MantisBugFile < ActiveRecord::Base
180 178 set_table_name :mantis_bug_file_table
181 179
182 180 def size
183 181 filesize
184 182 end
185 183
186 184 def original_filename
187 185 MantisMigrate.encode(filename)
188 186 end
189 187
190 188 def content_type
191 189 file_type
192 190 end
193 191
194 192 def read(*args)
195 193 if @read_finished
196 194 nil
197 195 else
198 196 @read_finished = true
199 197 content
200 198 end
201 199 end
202 200 end
203 201
204 202 class MantisBugRelationship < ActiveRecord::Base
205 203 set_table_name :mantis_bug_relationship_table
206 204 end
207 205
208 206 class MantisBugMonitor < ActiveRecord::Base
209 207 set_table_name :mantis_bug_monitor_table
210 208 end
211 209
212 210 class MantisNews < ActiveRecord::Base
213 211 set_table_name :mantis_news_table
214 212 end
215 213
216 214 class MantisCustomField < ActiveRecord::Base
217 215 set_table_name :mantis_custom_field_table
218 216 set_inheritance_column :none
219 217 has_many :values, :class_name => "MantisCustomFieldString", :foreign_key => :field_id
220 218 has_many :projects, :class_name => "MantisCustomFieldProject", :foreign_key => :field_id
221 219
222 220 def format
223 221 read_attribute :type
224 222 end
225 223
226 224 def name
227 read_attribute(:name)[0..29].gsub(/[^\w\s\'\-]/, '-')
225 read_attribute(:name)[0..29]
228 226 end
229 227 end
230 228
231 229 class MantisCustomFieldProject < ActiveRecord::Base
232 230 set_table_name :mantis_custom_field_project_table
233 231 end
234 232
235 233 class MantisCustomFieldString < ActiveRecord::Base
236 234 set_table_name :mantis_custom_field_string_table
237 235 end
238 236
239 237
240 238 def self.migrate
241 239
242 240 # Users
243 241 print "Migrating users"
244 242 User.delete_all "login <> 'admin'"
245 243 users_map = {}
246 244 users_migrated = 0
247 245 MantisUser.find(:all).each do |user|
248 246 u = User.new :firstname => encode(user.firstname),
249 247 :lastname => encode(user.lastname),
250 248 :mail => user.email,
251 249 :last_login_on => user.last_visit
252 250 u.login = user.username
253 251 u.password = 'mantis'
254 252 u.status = User::STATUS_LOCKED if user.enabled != 1
255 253 u.admin = true if user.access_level == 90
256 254 next unless u.save!
257 255 users_migrated += 1
258 256 users_map[user.id] = u.id
259 257 print '.'
260 258 end
261 259 puts
262 260
263 261 # Projects
264 262 print "Migrating projects"
265 263 Project.destroy_all
266 264 projects_map = {}
267 265 versions_map = {}
268 266 categories_map = {}
269 267 MantisProject.find(:all).each do |project|
270 268 p = Project.new :name => encode(project.name),
271 269 :description => encode(project.description)
272 270 p.identifier = project.identifier
273 271 next unless p.save
274 272 projects_map[project.id] = p.id
275 273 p.enabled_module_names = ['issue_tracking', 'news', 'wiki']
276 274 p.trackers << TRACKER_BUG
277 275 p.trackers << TRACKER_FEATURE
278 276 print '.'
279 277
280 278 # Project members
281 279 project.members.each do |member|
282 280 m = Member.new :user => User.find_by_id(users_map[member.user_id]),
283 281 :roles => [ROLE_MAPPING[member.access_level] || DEFAULT_ROLE]
284 282 m.project = p
285 283 m.save
286 284 end
287 285
288 286 # Project versions
289 287 project.versions.each do |version|
290 288 v = Version.new :name => encode(version.version),
291 289 :description => encode(version.description),
292 290 :effective_date => version.date_order.to_date
293 291 v.project = p
294 292 v.save
295 293 versions_map[version.id] = v.id
296 294 end
297 295
298 296 # Project categories
299 297 project.categories.each do |category|
300 298 g = IssueCategory.new :name => category.category[0,30]
301 299 g.project = p
302 300 g.save
303 301 categories_map[category.category] = g.id
304 302 end
305 303 end
306 304 puts
307 305
308 306 # Bugs
309 307 print "Migrating bugs"
310 308 Issue.destroy_all
311 309 issues_map = {}
312 310 keep_bug_ids = (Issue.count == 0)
313 311 MantisBug.find_each(:batch_size => 200) do |bug|
314 312 next unless projects_map[bug.project_id] && users_map[bug.reporter_id]
315 313 i = Issue.new :project_id => projects_map[bug.project_id],
316 314 :subject => encode(bug.summary),
317 315 :description => encode(bug.bug_text.full_description),
318 316 :priority => PRIORITY_MAPPING[bug.priority] || DEFAULT_PRIORITY,
319 317 :created_on => bug.date_submitted,
320 318 :updated_on => bug.last_updated
321 319 i.author = User.find_by_id(users_map[bug.reporter_id])
322 320 i.category = IssueCategory.find_by_project_id_and_name(i.project_id, bug.category[0,30]) unless bug.category.blank?
323 321 i.fixed_version = Version.find_by_project_id_and_name(i.project_id, bug.fixed_in_version) unless bug.fixed_in_version.blank?
324 322 i.status = STATUS_MAPPING[bug.status] || DEFAULT_STATUS
325 323 i.tracker = (bug.severity == 10 ? TRACKER_FEATURE : TRACKER_BUG)
326 324 i.id = bug.id if keep_bug_ids
327 325 next unless i.save
328 326 issues_map[bug.id] = i.id
329 327 print '.'
330 328 STDOUT.flush
331 329
332 330 # Assignee
333 331 # Redmine checks that the assignee is a project member
334 332 if (bug.handler_id && users_map[bug.handler_id])
335 333 i.assigned_to = User.find_by_id(users_map[bug.handler_id])
336 334 i.save_with_validation(false)
337 335 end
338 336
339 337 # Bug notes
340 338 bug.bug_notes.each do |note|
341 339 next unless users_map[note.reporter_id]
342 340 n = Journal.new :notes => encode(note.bug_note_text.note),
343 341 :created_on => note.date_submitted
344 342 n.user = User.find_by_id(users_map[note.reporter_id])
345 343 n.journalized = i
346 344 n.save
347 345 end
348 346
349 347 # Bug files
350 348 bug.bug_files.each do |file|
351 349 a = Attachment.new :created_on => file.date_added
352 350 a.file = file
353 351 a.author = User.find :first
354 352 a.container = i
355 353 a.save
356 354 end
357 355
358 356 # Bug monitors
359 357 bug.bug_monitors.each do |monitor|
360 358 next unless users_map[monitor.user_id]
361 359 i.add_watcher(User.find_by_id(users_map[monitor.user_id]))
362 360 end
363 361 end
364 362
365 363 # update issue id sequence if needed (postgresql)
366 364 Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
367 365 puts
368 366
369 367 # Bug relationships
370 368 print "Migrating bug relations"
371 369 MantisBugRelationship.find(:all).each do |relation|
372 370 next unless issues_map[relation.source_bug_id] && issues_map[relation.destination_bug_id]
373 371 r = IssueRelation.new :relation_type => RELATION_TYPE_MAPPING[relation.relationship_type]
374 372 r.issue_from = Issue.find_by_id(issues_map[relation.source_bug_id])
375 373 r.issue_to = Issue.find_by_id(issues_map[relation.destination_bug_id])
376 374 pp r unless r.save
377 375 print '.'
378 376 STDOUT.flush
379 377 end
380 378 puts
381 379
382 380 # News
383 381 print "Migrating news"
384 382 News.destroy_all
385 383 MantisNews.find(:all, :conditions => 'project_id > 0').each do |news|
386 384 next unless projects_map[news.project_id]
387 385 n = News.new :project_id => projects_map[news.project_id],
388 386 :title => encode(news.headline[0..59]),
389 387 :description => encode(news.body),
390 388 :created_on => news.date_posted
391 389 n.author = User.find_by_id(users_map[news.poster_id])
392 390 n.save
393 391 print '.'
394 392 STDOUT.flush
395 393 end
396 394 puts
397 395
398 396 # Custom fields
399 397 print "Migrating custom fields"
400 398 IssueCustomField.destroy_all
401 399 MantisCustomField.find(:all).each do |field|
402 400 f = IssueCustomField.new :name => field.name[0..29],
403 401 :field_format => CUSTOM_FIELD_TYPE_MAPPING[field.format],
404 402 :min_length => field.length_min,
405 403 :max_length => field.length_max,
406 404 :regexp => field.valid_regexp,
407 405 :possible_values => field.possible_values.split('|'),
408 406 :is_required => field.require_report?
409 407 next unless f.save
410 408 print '.'
411 409 STDOUT.flush
412 410 # Trackers association
413 411 f.trackers = Tracker.find :all
414 412
415 413 # Projects association
416 414 field.projects.each do |project|
417 415 f.projects << Project.find_by_id(projects_map[project.project_id]) if projects_map[project.project_id]
418 416 end
419 417
420 418 # Values
421 419 field.values.each do |value|
422 420 v = CustomValue.new :custom_field_id => f.id,
423 421 :value => value.value
424 422 v.customized = Issue.find_by_id(issues_map[value.bug_id]) if issues_map[value.bug_id]
425 423 v.save
426 424 end unless f.new_record?
427 425 end
428 426 puts
429 427
430 428 puts
431 429 puts "Users: #{users_migrated}/#{MantisUser.count}"
432 430 puts "Projects: #{Project.count}/#{MantisProject.count}"
433 431 puts "Memberships: #{Member.count}/#{MantisProjectUser.count}"
434 432 puts "Versions: #{Version.count}/#{MantisVersion.count}"
435 433 puts "Categories: #{IssueCategory.count}/#{MantisCategory.count}"
436 434 puts "Bugs: #{Issue.count}/#{MantisBug.count}"
437 435 puts "Bug notes: #{Journal.count}/#{MantisBugNote.count}"
438 436 puts "Bug files: #{Attachment.count}/#{MantisBugFile.count}"
439 437 puts "Bug relations: #{IssueRelation.count}/#{MantisBugRelationship.count}"
440 438 puts "Bug monitors: #{Watcher.count}/#{MantisBugMonitor.count}"
441 439 puts "News: #{News.count}/#{MantisNews.count}"
442 440 puts "Custom fields: #{IssueCustomField.count}/#{MantisCustomField.count}"
443 441 end
444 442
445 443 def self.encoding(charset)
446 444 @ic = Iconv.new('UTF-8', charset)
447 445 rescue Iconv::InvalidEncoding
448 446 return false
449 447 end
450 448
451 449 def self.establish_connection(params)
452 450 constants.each do |const|
453 451 klass = const_get(const)
454 452 next unless klass.respond_to? 'establish_connection'
455 453 klass.establish_connection params
456 454 end
457 455 end
458 456
459 457 def self.encode(text)
460 458 @ic.iconv text
461 459 rescue
462 460 text
463 461 end
464 462 end
465 463
466 464 puts
467 465 if Redmine::DefaultData::Loader.no_data?
468 466 puts "Redmine configuration need to be loaded before importing data."
469 467 puts "Please, run this first:"
470 468 puts
471 469 puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
472 470 exit
473 471 end
474 472
475 473 puts "WARNING: Your Redmine data will be deleted during this process."
476 474 print "Are you sure you want to continue ? [y/N] "
477 475 STDOUT.flush
478 476 break unless STDIN.gets.match(/^y$/i)
479 477
480 478 # Default Mantis database settings
481 479 db_params = {:adapter => 'mysql',
482 480 :database => 'bugtracker',
483 481 :host => 'localhost',
484 482 :username => 'root',
485 483 :password => '' }
486 484
487 485 puts
488 486 puts "Please enter settings for your Mantis database"
489 487 [:adapter, :host, :database, :username, :password].each do |param|
490 488 print "#{param} [#{db_params[param]}]: "
491 489 value = STDIN.gets.chomp!
492 490 db_params[param] = value unless value.blank?
493 491 end
494 492
495 493 while true
496 494 print "encoding [UTF-8]: "
497 495 STDOUT.flush
498 496 encoding = STDIN.gets.chomp!
499 497 encoding = 'UTF-8' if encoding.blank?
500 498 break if MantisMigrate.encoding encoding
501 499 puts "Invalid encoding!"
502 500 end
503 501 puts
504 502
505 503 # Make sure bugs can refer bugs in other projects
506 504 Setting.cross_project_issue_relations = 1 if Setting.respond_to? 'cross_project_issue_relations'
507 505
508 506 # Turn off email notifications
509 507 Setting.notified_events = []
510 508
511 509 MantisMigrate.establish_connection db_params
512 510 MantisMigrate.migrate
513 511 end
514 512 end
@@ -1,768 +1,768
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'active_record'
19 19 require 'iconv'
20 20 require 'pp'
21 21
22 22 namespace :redmine do
23 23 desc 'Trac migration script'
24 24 task :migrate_from_trac => :environment do
25 25
26 26 module TracMigrate
27 27 TICKET_MAP = []
28 28
29 29 DEFAULT_STATUS = IssueStatus.default
30 30 assigned_status = IssueStatus.find_by_position(2)
31 31 resolved_status = IssueStatus.find_by_position(3)
32 32 feedback_status = IssueStatus.find_by_position(4)
33 33 closed_status = IssueStatus.find :first, :conditions => { :is_closed => true }
34 34 STATUS_MAPPING = {'new' => DEFAULT_STATUS,
35 35 'reopened' => feedback_status,
36 36 'assigned' => assigned_status,
37 37 'closed' => closed_status
38 38 }
39 39
40 40 priorities = IssuePriority.all
41 41 DEFAULT_PRIORITY = priorities[0]
42 42 PRIORITY_MAPPING = {'lowest' => priorities[0],
43 43 'low' => priorities[0],
44 44 'normal' => priorities[1],
45 45 'high' => priorities[2],
46 46 'highest' => priorities[3],
47 47 # ---
48 48 'trivial' => priorities[0],
49 49 'minor' => priorities[1],
50 50 'major' => priorities[2],
51 51 'critical' => priorities[3],
52 52 'blocker' => priorities[4]
53 53 }
54 54
55 55 TRACKER_BUG = Tracker.find_by_position(1)
56 56 TRACKER_FEATURE = Tracker.find_by_position(2)
57 57 DEFAULT_TRACKER = TRACKER_BUG
58 58 TRACKER_MAPPING = {'defect' => TRACKER_BUG,
59 59 'enhancement' => TRACKER_FEATURE,
60 60 'task' => TRACKER_FEATURE,
61 61 'patch' =>TRACKER_FEATURE
62 62 }
63 63
64 64 roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC')
65 65 manager_role = roles[0]
66 66 developer_role = roles[1]
67 67 DEFAULT_ROLE = roles.last
68 68 ROLE_MAPPING = {'admin' => manager_role,
69 69 'developer' => developer_role
70 70 }
71 71
72 72 class ::Time
73 73 class << self
74 74 alias :real_now :now
75 75 def now
76 76 real_now - @fake_diff.to_i
77 77 end
78 78 def fake(time)
79 79 @fake_diff = real_now - time
80 80 res = yield
81 81 @fake_diff = 0
82 82 res
83 83 end
84 84 end
85 85 end
86 86
87 87 class TracComponent < ActiveRecord::Base
88 88 set_table_name :component
89 89 end
90 90
91 91 class TracMilestone < ActiveRecord::Base
92 92 set_table_name :milestone
93 93 # If this attribute is set a milestone has a defined target timepoint
94 94 def due
95 95 if read_attribute(:due) && read_attribute(:due) > 0
96 96 Time.at(read_attribute(:due)).to_date
97 97 else
98 98 nil
99 99 end
100 100 end
101 101 # This is the real timepoint at which the milestone has finished.
102 102 def completed
103 103 if read_attribute(:completed) && read_attribute(:completed) > 0
104 104 Time.at(read_attribute(:completed)).to_date
105 105 else
106 106 nil
107 107 end
108 108 end
109 109
110 110 def description
111 111 # Attribute is named descr in Trac v0.8.x
112 112 has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
113 113 end
114 114 end
115 115
116 116 class TracTicketCustom < ActiveRecord::Base
117 117 set_table_name :ticket_custom
118 118 end
119 119
120 120 class TracAttachment < ActiveRecord::Base
121 121 set_table_name :attachment
122 122 set_inheritance_column :none
123 123
124 124 def time; Time.at(read_attribute(:time)) end
125 125
126 126 def original_filename
127 127 filename
128 128 end
129 129
130 130 def content_type
131 131 ''
132 132 end
133 133
134 134 def exist?
135 135 File.file? trac_fullpath
136 136 end
137 137
138 138 def open
139 139 File.open("#{trac_fullpath}", 'rb') {|f|
140 140 @file = f
141 141 yield self
142 142 }
143 143 end
144 144
145 145 def read(*args)
146 146 @file.read(*args)
147 147 end
148 148
149 149 def description
150 150 read_attribute(:description).to_s.slice(0,255)
151 151 end
152 152
153 153 private
154 154 def trac_fullpath
155 155 attachment_type = read_attribute(:type)
156 156 trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
157 157 "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
158 158 end
159 159 end
160 160
161 161 class TracTicket < ActiveRecord::Base
162 162 set_table_name :ticket
163 163 set_inheritance_column :none
164 164
165 165 # ticket changes: only migrate status changes and comments
166 166 has_many :changes, :class_name => "TracTicketChange", :foreign_key => :ticket
167 167 has_many :attachments, :class_name => "TracAttachment",
168 168 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
169 169 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'ticket'" +
170 170 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{TracMigrate::TracAttachment.connection.quote_string(id.to_s)}\''
171 171 has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
172 172
173 173 def ticket_type
174 174 read_attribute(:type)
175 175 end
176 176
177 177 def summary
178 178 read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
179 179 end
180 180
181 181 def description
182 182 read_attribute(:description).blank? ? summary : read_attribute(:description)
183 183 end
184 184
185 185 def time; Time.at(read_attribute(:time)) end
186 186 def changetime; Time.at(read_attribute(:changetime)) end
187 187 end
188 188
189 189 class TracTicketChange < ActiveRecord::Base
190 190 set_table_name :ticket_change
191 191
192 192 def time; Time.at(read_attribute(:time)) end
193 193 end
194 194
195 195 TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
196 196 TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
197 197 TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
198 198 TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
199 199 TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
200 200 WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
201 201 CamelCase TitleIndex)
202 202
203 203 class TracWikiPage < ActiveRecord::Base
204 204 set_table_name :wiki
205 205 set_primary_key :name
206 206
207 207 has_many :attachments, :class_name => "TracAttachment",
208 208 :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
209 209 " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'wiki'" +
210 210 ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{TracMigrate::TracAttachment.connection.quote_string(id.to_s)}\''
211 211
212 212 def self.columns
213 213 # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
214 214 super.select {|column| column.name.to_s != 'readonly'}
215 215 end
216 216
217 217 def time; Time.at(read_attribute(:time)) end
218 218 end
219 219
220 220 class TracPermission < ActiveRecord::Base
221 221 set_table_name :permission
222 222 end
223 223
224 224 class TracSessionAttribute < ActiveRecord::Base
225 225 set_table_name :session_attribute
226 226 end
227 227
228 228 def self.find_or_create_user(username, project_member = false)
229 229 return User.anonymous if username.blank?
230 230
231 231 u = User.find_by_login(username)
232 232 if !u
233 233 # Create a new user if not found
234 234 mail = username[0,limit_for(User, 'mail')]
235 235 if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
236 236 mail = mail_attr.value
237 237 end
238 238 mail = "#{mail}@foo.bar" unless mail.include?("@")
239 239
240 240 name = username
241 241 if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
242 242 name = name_attr.value
243 243 end
244 244 name =~ (/(.*)(\s+\w+)?/)
245 245 fn = $1.strip
246 246 ln = ($2 || '-').strip
247 247
248 248 u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
249 :firstname => fn[0, limit_for(User, 'firstname')].gsub(/[^\w\s\'\-]/i, '-'),
250 :lastname => ln[0, limit_for(User, 'lastname')].gsub(/[^\w\s\'\-]/i, '-')
249 :firstname => fn[0, limit_for(User, 'firstname')],
250 :lastname => ln[0, limit_for(User, 'lastname')]
251 251
252 252 u.login = username[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-')
253 253 u.password = 'trac'
254 254 u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
255 255 # finally, a default user is used if the new user is not valid
256 256 u = User.find(:first) unless u.save
257 257 end
258 258 # Make sure he is a member of the project
259 259 if project_member && !u.member_of?(@target_project)
260 260 role = DEFAULT_ROLE
261 261 if u.admin
262 262 role = ROLE_MAPPING['admin']
263 263 elsif TracPermission.find_by_username_and_action(username, 'developer')
264 264 role = ROLE_MAPPING['developer']
265 265 end
266 266 Member.create(:user => u, :project => @target_project, :roles => [role])
267 267 u.reload
268 268 end
269 269 u
270 270 end
271 271
272 272 # Basic wiki syntax conversion
273 273 def self.convert_wiki_text(text)
274 274 # Titles
275 275 text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
276 276 # External Links
277 277 text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
278 278 # Ticket links:
279 279 # [ticket:234 Text],[ticket:234 This is a test]
280 280 text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
281 281 # ticket:1234
282 282 # #1 is working cause Redmine uses the same syntax.
283 283 text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
284 284 # Milestone links:
285 285 # [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
286 286 # The text "Milestone 0.1.0 (Mercury)" is not converted,
287 287 # cause Redmine's wiki does not support this.
288 288 text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
289 289 # [milestone:"0.1.0 Mercury"]
290 290 text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
291 291 text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
292 292 # milestone:0.1.0
293 293 text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
294 294 text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
295 295 # Internal Links
296 296 text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
297 297 text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
298 298 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
299 299 text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
300 300 text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
301 301 text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
302 302
303 303 # Links to pages UsingJustWikiCaps
304 304 text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
305 305 # Normalize things that were supposed to not be links
306 306 # like !NotALink
307 307 text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
308 308 # Revisions links
309 309 text = text.gsub(/\[(\d+)\]/, 'r\1')
310 310 # Ticket number re-writing
311 311 text = text.gsub(/#(\d+)/) do |s|
312 312 if $1.length < 10
313 313 # TICKET_MAP[$1.to_i] ||= $1
314 314 "\##{TICKET_MAP[$1.to_i] || $1}"
315 315 else
316 316 s
317 317 end
318 318 end
319 319 # We would like to convert the Code highlighting too
320 320 # This will go into the next line.
321 321 shebang_line = false
322 322 # Reguar expression for start of code
323 323 pre_re = /\{\{\{/
324 324 # Code hightlighing...
325 325 shebang_re = /^\#\!([a-z]+)/
326 326 # Regular expression for end of code
327 327 pre_end_re = /\}\}\}/
328 328
329 329 # Go through the whole text..extract it line by line
330 330 text = text.gsub(/^(.*)$/) do |line|
331 331 m_pre = pre_re.match(line)
332 332 if m_pre
333 333 line = '<pre>'
334 334 else
335 335 m_sl = shebang_re.match(line)
336 336 if m_sl
337 337 shebang_line = true
338 338 line = '<code class="' + m_sl[1] + '">'
339 339 end
340 340 m_pre_end = pre_end_re.match(line)
341 341 if m_pre_end
342 342 line = '</pre>'
343 343 if shebang_line
344 344 line = '</code>' + line
345 345 end
346 346 end
347 347 end
348 348 line
349 349 end
350 350
351 351 # Highlighting
352 352 text = text.gsub(/'''''([^\s])/, '_*\1')
353 353 text = text.gsub(/([^\s])'''''/, '\1*_')
354 354 text = text.gsub(/'''/, '*')
355 355 text = text.gsub(/''/, '_')
356 356 text = text.gsub(/__/, '+')
357 357 text = text.gsub(/~~/, '-')
358 358 text = text.gsub(/`/, '@')
359 359 text = text.gsub(/,,/, '~')
360 360 # Lists
361 361 text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
362 362
363 363 text
364 364 end
365 365
366 366 def self.migrate
367 367 establish_connection
368 368
369 369 # Quick database test
370 370 TracComponent.count
371 371
372 372 migrated_components = 0
373 373 migrated_milestones = 0
374 374 migrated_tickets = 0
375 375 migrated_custom_values = 0
376 376 migrated_ticket_attachments = 0
377 377 migrated_wiki_edits = 0
378 378 migrated_wiki_attachments = 0
379 379
380 380 #Wiki system initializing...
381 381 @target_project.wiki.destroy if @target_project.wiki
382 382 @target_project.reload
383 383 wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
384 384 wiki_edit_count = 0
385 385
386 386 # Components
387 387 print "Migrating components"
388 388 issues_category_map = {}
389 389 TracComponent.find(:all).each do |component|
390 390 print '.'
391 391 STDOUT.flush
392 392 c = IssueCategory.new :project => @target_project,
393 393 :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
394 394 next unless c.save
395 395 issues_category_map[component.name] = c
396 396 migrated_components += 1
397 397 end
398 398 puts
399 399
400 400 # Milestones
401 401 print "Migrating milestones"
402 402 version_map = {}
403 403 TracMilestone.find(:all).each do |milestone|
404 404 print '.'
405 405 STDOUT.flush
406 406 # First we try to find the wiki page...
407 407 p = wiki.find_or_new_page(milestone.name.to_s)
408 408 p.content = WikiContent.new(:page => p) if p.new_record?
409 409 p.content.text = milestone.description.to_s
410 410 p.content.author = find_or_create_user('trac')
411 411 p.content.comments = 'Milestone'
412 412 p.save
413 413
414 414 v = Version.new :project => @target_project,
415 415 :name => encode(milestone.name[0, limit_for(Version, 'name')]),
416 416 :description => nil,
417 417 :wiki_page_title => milestone.name.to_s,
418 418 :effective_date => milestone.completed
419 419
420 420 next unless v.save
421 421 version_map[milestone.name] = v
422 422 migrated_milestones += 1
423 423 end
424 424 puts
425 425
426 426 # Custom fields
427 427 # TODO: read trac.ini instead
428 428 print "Migrating custom fields"
429 429 custom_field_map = {}
430 430 TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
431 431 print '.'
432 432 STDOUT.flush
433 433 # Redmine custom field name
434 434 field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
435 435 # Find if the custom already exists in Redmine
436 436 f = IssueCustomField.find_by_name(field_name)
437 437 # Or create a new one
438 438 f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
439 439 :field_format => 'string')
440 440
441 441 next if f.new_record?
442 442 f.trackers = Tracker.find(:all)
443 443 f.projects << @target_project
444 444 custom_field_map[field.name] = f
445 445 end
446 446 puts
447 447
448 448 # Trac 'resolution' field as a Redmine custom field
449 449 r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
450 450 r = IssueCustomField.new(:name => 'Resolution',
451 451 :field_format => 'list',
452 452 :is_filter => true) if r.nil?
453 453 r.trackers = Tracker.find(:all)
454 454 r.projects << @target_project
455 455 r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
456 456 r.save!
457 457 custom_field_map['resolution'] = r
458 458
459 459 # Tickets
460 460 print "Migrating tickets"
461 461 TracTicket.find_each(:batch_size => 200) do |ticket|
462 462 print '.'
463 463 STDOUT.flush
464 464 i = Issue.new :project => @target_project,
465 465 :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
466 466 :description => convert_wiki_text(encode(ticket.description)),
467 467 :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
468 468 :created_on => ticket.time
469 469 i.author = find_or_create_user(ticket.reporter)
470 470 i.category = issues_category_map[ticket.component] unless ticket.component.blank?
471 471 i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
472 472 i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
473 473 i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
474 474 i.id = ticket.id unless Issue.exists?(ticket.id)
475 475 next unless Time.fake(ticket.changetime) { i.save }
476 476 TICKET_MAP[ticket.id] = i.id
477 477 migrated_tickets += 1
478 478
479 479 # Owner
480 480 unless ticket.owner.blank?
481 481 i.assigned_to = find_or_create_user(ticket.owner, true)
482 482 Time.fake(ticket.changetime) { i.save }
483 483 end
484 484
485 485 # Comments and status/resolution changes
486 486 ticket.changes.group_by(&:time).each do |time, changeset|
487 487 status_change = changeset.select {|change| change.field == 'status'}.first
488 488 resolution_change = changeset.select {|change| change.field == 'resolution'}.first
489 489 comment_change = changeset.select {|change| change.field == 'comment'}.first
490 490
491 491 n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
492 492 :created_on => time
493 493 n.user = find_or_create_user(changeset.first.author)
494 494 n.journalized = i
495 495 if status_change &&
496 496 STATUS_MAPPING[status_change.oldvalue] &&
497 497 STATUS_MAPPING[status_change.newvalue] &&
498 498 (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
499 499 n.details << JournalDetail.new(:property => 'attr',
500 500 :prop_key => 'status_id',
501 501 :old_value => STATUS_MAPPING[status_change.oldvalue].id,
502 502 :value => STATUS_MAPPING[status_change.newvalue].id)
503 503 end
504 504 if resolution_change
505 505 n.details << JournalDetail.new(:property => 'cf',
506 506 :prop_key => custom_field_map['resolution'].id,
507 507 :old_value => resolution_change.oldvalue,
508 508 :value => resolution_change.newvalue)
509 509 end
510 510 n.save unless n.details.empty? && n.notes.blank?
511 511 end
512 512
513 513 # Attachments
514 514 ticket.attachments.each do |attachment|
515 515 next unless attachment.exist?
516 516 attachment.open {
517 517 a = Attachment.new :created_on => attachment.time
518 518 a.file = attachment
519 519 a.author = find_or_create_user(attachment.author)
520 520 a.container = i
521 521 a.description = attachment.description
522 522 migrated_ticket_attachments += 1 if a.save
523 523 }
524 524 end
525 525
526 526 # Custom fields
527 527 custom_values = ticket.customs.inject({}) do |h, custom|
528 528 if custom_field = custom_field_map[custom.name]
529 529 h[custom_field.id] = custom.value
530 530 migrated_custom_values += 1
531 531 end
532 532 h
533 533 end
534 534 if custom_field_map['resolution'] && !ticket.resolution.blank?
535 535 custom_values[custom_field_map['resolution'].id] = ticket.resolution
536 536 end
537 537 i.custom_field_values = custom_values
538 538 i.save_custom_field_values
539 539 end
540 540
541 541 # update issue id sequence if needed (postgresql)
542 542 Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
543 543 puts
544 544
545 545 # Wiki
546 546 print "Migrating wiki"
547 547 if wiki.save
548 548 TracWikiPage.find(:all, :order => 'name, version').each do |page|
549 549 # Do not migrate Trac manual wiki pages
550 550 next if TRAC_WIKI_PAGES.include?(page.name)
551 551 wiki_edit_count += 1
552 552 print '.'
553 553 STDOUT.flush
554 554 p = wiki.find_or_new_page(page.name)
555 555 p.content = WikiContent.new(:page => p) if p.new_record?
556 556 p.content.text = page.text
557 557 p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
558 558 p.content.comments = page.comment
559 559 Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
560 560
561 561 next if p.content.new_record?
562 562 migrated_wiki_edits += 1
563 563
564 564 # Attachments
565 565 page.attachments.each do |attachment|
566 566 next unless attachment.exist?
567 567 next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
568 568 attachment.open {
569 569 a = Attachment.new :created_on => attachment.time
570 570 a.file = attachment
571 571 a.author = find_or_create_user(attachment.author)
572 572 a.description = attachment.description
573 573 a.container = p
574 574 migrated_wiki_attachments += 1 if a.save
575 575 }
576 576 end
577 577 end
578 578
579 579 wiki.reload
580 580 wiki.pages.each do |page|
581 581 page.content.text = convert_wiki_text(page.content.text)
582 582 Time.fake(page.content.updated_on) { page.content.save }
583 583 end
584 584 end
585 585 puts
586 586
587 587 puts
588 588 puts "Components: #{migrated_components}/#{TracComponent.count}"
589 589 puts "Milestones: #{migrated_milestones}/#{TracMilestone.count}"
590 590 puts "Tickets: #{migrated_tickets}/#{TracTicket.count}"
591 591 puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
592 592 puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
593 593 puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edit_count}"
594 594 puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
595 595 end
596 596
597 597 def self.limit_for(klass, attribute)
598 598 klass.columns_hash[attribute.to_s].limit
599 599 end
600 600
601 601 def self.encoding(charset)
602 602 @ic = Iconv.new('UTF-8', charset)
603 603 rescue Iconv::InvalidEncoding
604 604 puts "Invalid encoding!"
605 605 return false
606 606 end
607 607
608 608 def self.set_trac_directory(path)
609 609 @@trac_directory = path
610 610 raise "This directory doesn't exist!" unless File.directory?(path)
611 611 raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
612 612 @@trac_directory
613 613 rescue Exception => e
614 614 puts e
615 615 return false
616 616 end
617 617
618 618 def self.trac_directory
619 619 @@trac_directory
620 620 end
621 621
622 622 def self.set_trac_adapter(adapter)
623 623 return false if adapter.blank?
624 624 raise "Unknown adapter: #{adapter}!" unless %w(sqlite sqlite3 mysql postgresql).include?(adapter)
625 625 # If adapter is sqlite or sqlite3, make sure that trac.db exists
626 626 raise "#{trac_db_path} doesn't exist!" if %w(sqlite sqlite3).include?(adapter) && !File.exist?(trac_db_path)
627 627 @@trac_adapter = adapter
628 628 rescue Exception => e
629 629 puts e
630 630 return false
631 631 end
632 632
633 633 def self.set_trac_db_host(host)
634 634 return nil if host.blank?
635 635 @@trac_db_host = host
636 636 end
637 637
638 638 def self.set_trac_db_port(port)
639 639 return nil if port.to_i == 0
640 640 @@trac_db_port = port.to_i
641 641 end
642 642
643 643 def self.set_trac_db_name(name)
644 644 return nil if name.blank?
645 645 @@trac_db_name = name
646 646 end
647 647
648 648 def self.set_trac_db_username(username)
649 649 @@trac_db_username = username
650 650 end
651 651
652 652 def self.set_trac_db_password(password)
653 653 @@trac_db_password = password
654 654 end
655 655
656 656 def self.set_trac_db_schema(schema)
657 657 @@trac_db_schema = schema
658 658 end
659 659
660 660 mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
661 661
662 662 def self.trac_db_path; "#{trac_directory}/db/trac.db" end
663 663 def self.trac_attachments_directory; "#{trac_directory}/attachments" end
664 664
665 665 def self.target_project_identifier(identifier)
666 666 project = Project.find_by_identifier(identifier)
667 667 if !project
668 668 # create the target project
669 669 project = Project.new :name => identifier.humanize,
670 670 :description => ''
671 671 project.identifier = identifier
672 672 puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
673 673 # enable issues and wiki for the created project
674 674 project.enabled_module_names = ['issue_tracking', 'wiki']
675 675 else
676 676 puts
677 677 puts "This project already exists in your Redmine database."
678 678 print "Are you sure you want to append data to this project ? [Y/n] "
679 679 STDOUT.flush
680 680 exit if STDIN.gets.match(/^n$/i)
681 681 end
682 682 project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
683 683 project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
684 684 @target_project = project.new_record? ? nil : project
685 685 @target_project.reload
686 686 end
687 687
688 688 def self.connection_params
689 689 if %w(sqlite sqlite3).include?(trac_adapter)
690 690 {:adapter => trac_adapter,
691 691 :database => trac_db_path}
692 692 else
693 693 {:adapter => trac_adapter,
694 694 :database => trac_db_name,
695 695 :host => trac_db_host,
696 696 :port => trac_db_port,
697 697 :username => trac_db_username,
698 698 :password => trac_db_password,
699 699 :schema_search_path => trac_db_schema
700 700 }
701 701 end
702 702 end
703 703
704 704 def self.establish_connection
705 705 constants.each do |const|
706 706 klass = const_get(const)
707 707 next unless klass.respond_to? 'establish_connection'
708 708 klass.establish_connection connection_params
709 709 end
710 710 end
711 711
712 712 private
713 713 def self.encode(text)
714 714 @ic.iconv text
715 715 rescue
716 716 text
717 717 end
718 718 end
719 719
720 720 puts
721 721 if Redmine::DefaultData::Loader.no_data?
722 722 puts "Redmine configuration need to be loaded before importing data."
723 723 puts "Please, run this first:"
724 724 puts
725 725 puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
726 726 exit
727 727 end
728 728
729 729 puts "WARNING: a new project will be added to Redmine during this process."
730 730 print "Are you sure you want to continue ? [y/N] "
731 731 STDOUT.flush
732 732 break unless STDIN.gets.match(/^y$/i)
733 733 puts
734 734
735 735 def prompt(text, options = {}, &block)
736 736 default = options[:default] || ''
737 737 while true
738 738 print "#{text} [#{default}]: "
739 739 STDOUT.flush
740 740 value = STDIN.gets.chomp!
741 741 value = default if value.blank?
742 742 break if yield value
743 743 end
744 744 end
745 745
746 746 DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
747 747
748 748 prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
749 749 prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite') {|adapter| TracMigrate.set_trac_adapter adapter}
750 750 unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
751 751 prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
752 752 prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
753 753 prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
754 754 prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
755 755 prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
756 756 prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
757 757 end
758 758 prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
759 759 prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
760 760 puts
761 761
762 762 # Turn off email notifications
763 763 Setting.notified_events = []
764 764
765 765 TracMigrate.migrate
766 766 end
767 767 end
768 768
General Comments 0
You need to be logged in to leave comments. Login now