##// END OF EJS Templates
Add length validations for string fields (#24283)....
Jean-Philippe Lang -
r15607:7b3f2b51c0ea
parent child
Show More
@@ -1,327 +1,328
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class CustomField < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::SubclassFactory
21 21
22 22 has_many :enumerations,
23 23 lambda { order(:position) },
24 24 :class_name => 'CustomFieldEnumeration',
25 25 :dependent => :delete_all
26 26 has_many :custom_values, :dependent => :delete_all
27 27 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
28 28 acts_as_positioned
29 29 serialize :possible_values
30 30 store :format_store
31 31
32 32 validates_presence_of :name, :field_format
33 33 validates_uniqueness_of :name, :scope => :type
34 34 validates_length_of :name, :maximum => 30
35 validates_length_of :regexp, maximum: 30
35 36 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
36 37 validate :validate_custom_field
37 38 attr_protected :id
38 39
39 40 before_validation :set_searchable
40 41 before_save do |field|
41 42 field.format.before_custom_field_save(field)
42 43 end
43 44 after_save :handle_multiplicity_change
44 45 after_save do |field|
45 46 if field.visible_changed? && field.visible
46 47 field.roles.clear
47 48 end
48 49 end
49 50
50 51 scope :sorted, lambda { order(:position) }
51 52 scope :visible, lambda {|*args|
52 53 user = args.shift || User.current
53 54 if user.admin?
54 55 # nop
55 56 elsif user.memberships.any?
56 57 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
57 58 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
58 59 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
59 60 " WHERE m.user_id = ?)",
60 61 true, user.id)
61 62 else
62 63 where(:visible => true)
63 64 end
64 65 }
65 66 def visible_by?(project, user=User.current)
66 67 visible? || user.admin?
67 68 end
68 69
69 70 safe_attributes 'name',
70 71 'field_format',
71 72 'possible_values',
72 73 'regexp',
73 74 'min_lnegth',
74 75 'max_length',
75 76 'is_required',
76 77 'is_for_all',
77 78 'is_filter',
78 79 'position',
79 80 'searchable',
80 81 'default_value',
81 82 'editable',
82 83 'visible',
83 84 'multiple',
84 85 'description',
85 86 'role_ids',
86 87 'url_pattern',
87 88 'text_formatting',
88 89 'edit_tag_style',
89 90 'user_role',
90 91 'version_status',
91 92 'extensions_allowed'
92 93
93 94 def format
94 95 @format ||= Redmine::FieldFormat.find(field_format)
95 96 end
96 97
97 98 def field_format=(arg)
98 99 # cannot change format of a saved custom field
99 100 if new_record?
100 101 @format = nil
101 102 super
102 103 end
103 104 end
104 105
105 106 def set_searchable
106 107 # make sure these fields are not searchable
107 108 self.searchable = false unless format.class.searchable_supported
108 109 # make sure only these fields can have multiple values
109 110 self.multiple = false unless format.class.multiple_supported
110 111 true
111 112 end
112 113
113 114 def validate_custom_field
114 115 format.validate_custom_field(self).each do |attribute, message|
115 116 errors.add attribute, message
116 117 end
117 118
118 119 if regexp.present?
119 120 begin
120 121 Regexp.new(regexp)
121 122 rescue
122 123 errors.add(:regexp, :invalid)
123 124 end
124 125 end
125 126
126 127 if default_value.present?
127 128 validate_field_value(default_value).each do |message|
128 129 errors.add :default_value, message
129 130 end
130 131 end
131 132 end
132 133
133 134 def possible_custom_value_options(custom_value)
134 135 format.possible_custom_value_options(custom_value)
135 136 end
136 137
137 138 def possible_values_options(object=nil)
138 139 if object.is_a?(Array)
139 140 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
140 141 else
141 142 format.possible_values_options(self, object) || []
142 143 end
143 144 end
144 145
145 146 def possible_values
146 147 values = read_attribute(:possible_values)
147 148 if values.is_a?(Array)
148 149 values.each do |value|
149 150 value.to_s.force_encoding('UTF-8')
150 151 end
151 152 values
152 153 else
153 154 []
154 155 end
155 156 end
156 157
157 158 # Makes possible_values accept a multiline string
158 159 def possible_values=(arg)
159 160 if arg.is_a?(Array)
160 161 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
161 162 write_attribute(:possible_values, values)
162 163 else
163 164 self.possible_values = arg.to_s.split(/[\n\r]+/)
164 165 end
165 166 end
166 167
167 168 def set_custom_field_value(custom_field_value, value)
168 169 format.set_custom_field_value(self, custom_field_value, value)
169 170 end
170 171
171 172 def cast_value(value)
172 173 format.cast_value(self, value)
173 174 end
174 175
175 176 def value_from_keyword(keyword, customized)
176 177 format.value_from_keyword(self, keyword, customized)
177 178 end
178 179
179 180 # Returns the options hash used to build a query filter for the field
180 181 def query_filter_options(query)
181 182 format.query_filter_options(self, query)
182 183 end
183 184
184 185 def totalable?
185 186 format.totalable_supported
186 187 end
187 188
188 189 # Returns a ORDER BY clause that can used to sort customized
189 190 # objects by their value of the custom field.
190 191 # Returns nil if the custom field can not be used for sorting.
191 192 def order_statement
192 193 return nil if multiple?
193 194 format.order_statement(self)
194 195 end
195 196
196 197 # Returns a GROUP BY clause that can used to group by custom value
197 198 # Returns nil if the custom field can not be used for grouping.
198 199 def group_statement
199 200 return nil if multiple?
200 201 format.group_statement(self)
201 202 end
202 203
203 204 def join_for_order_statement
204 205 format.join_for_order_statement(self)
205 206 end
206 207
207 208 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
208 209 if visible? || user.admin?
209 210 "1=1"
210 211 elsif user.anonymous?
211 212 "1=0"
212 213 else
213 214 project_key ||= "#{self.class.customized_class.table_name}.project_id"
214 215 id_column ||= id
215 216 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
216 217 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
217 218 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
218 219 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
219 220 end
220 221 end
221 222
222 223 def self.visibility_condition
223 224 if user.admin?
224 225 "1=1"
225 226 elsif user.anonymous?
226 227 "#{table_name}.visible"
227 228 else
228 229 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
229 230 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
230 231 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
231 232 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
232 233 end
233 234 end
234 235
235 236 def <=>(field)
236 237 position <=> field.position
237 238 end
238 239
239 240 # Returns the class that values represent
240 241 def value_class
241 242 format.target_class if format.respond_to?(:target_class)
242 243 end
243 244
244 245 def self.customized_class
245 246 self.name =~ /^(.+)CustomField$/
246 247 $1.constantize rescue nil
247 248 end
248 249
249 250 # to move in project_custom_field
250 251 def self.for_all
251 252 where(:is_for_all => true).order('position').to_a
252 253 end
253 254
254 255 def type_name
255 256 nil
256 257 end
257 258
258 259 # Returns the error messages for the given value
259 260 # or an empty array if value is a valid value for the custom field
260 261 def validate_custom_value(custom_value)
261 262 value = custom_value.value
262 263 errs = format.validate_custom_value(custom_value)
263 264
264 265 unless errs.any?
265 266 if value.is_a?(Array)
266 267 if !multiple?
267 268 errs << ::I18n.t('activerecord.errors.messages.invalid')
268 269 end
269 270 if is_required? && value.detect(&:present?).nil?
270 271 errs << ::I18n.t('activerecord.errors.messages.blank')
271 272 end
272 273 else
273 274 if is_required? && value.blank?
274 275 errs << ::I18n.t('activerecord.errors.messages.blank')
275 276 end
276 277 end
277 278 end
278 279
279 280 errs
280 281 end
281 282
282 283 # Returns the error messages for the default custom field value
283 284 def validate_field_value(value)
284 285 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
285 286 end
286 287
287 288 # Returns true if value is a valid value for the custom field
288 289 def valid_field_value?(value)
289 290 validate_field_value(value).empty?
290 291 end
291 292
292 293 def after_save_custom_value(custom_value)
293 294 format.after_save_custom_value(self, custom_value)
294 295 end
295 296
296 297 def format_in?(*args)
297 298 args.include?(field_format)
298 299 end
299 300
300 301 def self.human_attribute_name(attribute_key_name, *args)
301 302 attr_name = attribute_key_name.to_s
302 303 if attr_name == 'url_pattern'
303 304 attr_name = "url"
304 305 end
305 306 super(attr_name, *args)
306 307 end
307 308
308 309 protected
309 310
310 311 # Removes multiple values for the custom field after setting the multiple attribute to false
311 312 # We kepp the value with the highest id for each customized object
312 313 def handle_multiplicity_change
313 314 if !new_record? && multiple_was && !multiple
314 315 ids = custom_values.
315 316 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
316 317 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
317 318 " AND cve.id > #{CustomValue.table_name}.id)").
318 319 pluck(:id)
319 320
320 321 if ids.any?
321 322 custom_values.where(:id => ids).delete_all
322 323 end
323 324 end
324 325 end
325 326 end
326 327
327 328 require_dependency 'redmine/field_format'
@@ -1,514 +1,516
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ScmFetchError < Exception; end
19 19
20 20 class Repository < ActiveRecord::Base
21 21 include Redmine::Ciphering
22 22 include Redmine::SafeAttributes
23 23
24 24 # Maximum length for repository identifiers
25 25 IDENTIFIER_MAX_LENGTH = 255
26 26
27 27 belongs_to :project
28 28 has_many :changesets, lambda{order("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC")}
29 29 has_many :filechanges, :class_name => 'Change', :through => :changesets
30 30
31 31 serialize :extra_info
32 32
33 33 before_validation :normalize_identifier
34 34 before_save :check_default
35 35
36 36 # Raw SQL to delete changesets and changes in the database
37 37 # has_many :changesets, :dependent => :destroy is too slow for big repositories
38 38 before_destroy :clear_changesets
39 39
40 validates_length_of :login, maximum: 60, allow_nil: true
40 41 validates_length_of :password, :maximum => 255, :allow_nil => true
42 validates_length_of :root_url, :url, maximum: 255
41 43 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
42 44 validates_uniqueness_of :identifier, :scope => :project_id
43 45 validates_exclusion_of :identifier, :in => %w(browse show entry raw changes annotate diff statistics graph revisions revision)
44 46 # donwcase letters, digits, dashes, underscores but not digits only
45 47 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
46 48 # Checks if the SCM is enabled when creating a repository
47 49 validate :repo_create_validation, :on => :create
48 50 validate :validate_repository_path
49 51 attr_protected :id
50 52
51 53 safe_attributes 'identifier',
52 54 'login',
53 55 'password',
54 56 'path_encoding',
55 57 'log_encoding',
56 58 'is_default'
57 59
58 60 safe_attributes 'url',
59 61 :if => lambda {|repository, user| repository.new_record?}
60 62
61 63 def repo_create_validation
62 64 unless Setting.enabled_scm.include?(self.class.name.demodulize)
63 65 errors.add(:type, :invalid)
64 66 end
65 67 end
66 68
67 69 def self.human_attribute_name(attribute_key_name, *args)
68 70 attr_name = attribute_key_name.to_s
69 71 if attr_name == "log_encoding"
70 72 attr_name = "commit_logs_encoding"
71 73 end
72 74 super(attr_name, *args)
73 75 end
74 76
75 77 # Removes leading and trailing whitespace
76 78 def url=(arg)
77 79 write_attribute(:url, arg ? arg.to_s.strip : nil)
78 80 end
79 81
80 82 # Removes leading and trailing whitespace
81 83 def root_url=(arg)
82 84 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
83 85 end
84 86
85 87 def password
86 88 read_ciphered_attribute(:password)
87 89 end
88 90
89 91 def password=(arg)
90 92 write_ciphered_attribute(:password, arg)
91 93 end
92 94
93 95 def scm_adapter
94 96 self.class.scm_adapter_class
95 97 end
96 98
97 99 def scm
98 100 unless @scm
99 101 @scm = self.scm_adapter.new(url, root_url,
100 102 login, password, path_encoding)
101 103 if root_url.blank? && @scm.root_url.present?
102 104 update_attribute(:root_url, @scm.root_url)
103 105 end
104 106 end
105 107 @scm
106 108 end
107 109
108 110 def scm_name
109 111 self.class.scm_name
110 112 end
111 113
112 114 def name
113 115 if identifier.present?
114 116 identifier
115 117 elsif is_default?
116 118 l(:field_repository_is_default)
117 119 else
118 120 scm_name
119 121 end
120 122 end
121 123
122 124 def identifier=(identifier)
123 125 super unless identifier_frozen?
124 126 end
125 127
126 128 def identifier_frozen?
127 129 errors[:identifier].blank? && !(new_record? || identifier.blank?)
128 130 end
129 131
130 132 def identifier_param
131 133 if is_default?
132 134 nil
133 135 elsif identifier.present?
134 136 identifier
135 137 else
136 138 id.to_s
137 139 end
138 140 end
139 141
140 142 def <=>(repository)
141 143 if is_default?
142 144 -1
143 145 elsif repository.is_default?
144 146 1
145 147 else
146 148 identifier.to_s <=> repository.identifier.to_s
147 149 end
148 150 end
149 151
150 152 def self.find_by_identifier_param(param)
151 153 if param.to_s =~ /^\d+$/
152 154 find_by_id(param)
153 155 else
154 156 find_by_identifier(param)
155 157 end
156 158 end
157 159
158 160 # TODO: should return an empty hash instead of nil to avoid many ||{}
159 161 def extra_info
160 162 h = read_attribute(:extra_info)
161 163 h.is_a?(Hash) ? h : nil
162 164 end
163 165
164 166 def merge_extra_info(arg)
165 167 h = extra_info || {}
166 168 return h if arg.nil?
167 169 h.merge!(arg)
168 170 write_attribute(:extra_info, h)
169 171 end
170 172
171 173 def report_last_commit
172 174 true
173 175 end
174 176
175 177 def supports_cat?
176 178 scm.supports_cat?
177 179 end
178 180
179 181 def supports_annotate?
180 182 scm.supports_annotate?
181 183 end
182 184
183 185 def supports_all_revisions?
184 186 true
185 187 end
186 188
187 189 def supports_directory_revisions?
188 190 false
189 191 end
190 192
191 193 def supports_revision_graph?
192 194 false
193 195 end
194 196
195 197 def entry(path=nil, identifier=nil)
196 198 scm.entry(path, identifier)
197 199 end
198 200
199 201 def scm_entries(path=nil, identifier=nil)
200 202 scm.entries(path, identifier)
201 203 end
202 204 protected :scm_entries
203 205
204 206 def entries(path=nil, identifier=nil)
205 207 entries = scm_entries(path, identifier)
206 208 load_entries_changesets(entries)
207 209 entries
208 210 end
209 211
210 212 def branches
211 213 scm.branches
212 214 end
213 215
214 216 def tags
215 217 scm.tags
216 218 end
217 219
218 220 def default_branch
219 221 nil
220 222 end
221 223
222 224 def properties(path, identifier=nil)
223 225 scm.properties(path, identifier)
224 226 end
225 227
226 228 def cat(path, identifier=nil)
227 229 scm.cat(path, identifier)
228 230 end
229 231
230 232 def diff(path, rev, rev_to)
231 233 scm.diff(path, rev, rev_to)
232 234 end
233 235
234 236 def diff_format_revisions(cs, cs_to, sep=':')
235 237 text = ""
236 238 text << cs_to.format_identifier + sep if cs_to
237 239 text << cs.format_identifier if cs
238 240 text
239 241 end
240 242
241 243 # Returns a path relative to the url of the repository
242 244 def relative_path(path)
243 245 path
244 246 end
245 247
246 248 # Finds and returns a revision with a number or the beginning of a hash
247 249 def find_changeset_by_name(name)
248 250 return nil if name.blank?
249 251 s = name.to_s
250 252 if s.match(/^\d*$/)
251 253 changesets.where("revision = ?", s).first
252 254 else
253 255 changesets.where("revision LIKE ?", s + '%').first
254 256 end
255 257 end
256 258
257 259 def latest_changeset
258 260 @latest_changeset ||= changesets.first
259 261 end
260 262
261 263 # Returns the latest changesets for +path+
262 264 # Default behaviour is to search in cached changesets
263 265 def latest_changesets(path, rev, limit=10)
264 266 if path.blank?
265 267 changesets.
266 268 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
267 269 limit(limit).
268 270 preload(:user).
269 271 to_a
270 272 else
271 273 filechanges.
272 274 where("path = ?", path.with_leading_slash).
273 275 reorder("#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC").
274 276 limit(limit).
275 277 preload(:changeset => :user).
276 278 collect(&:changeset)
277 279 end
278 280 end
279 281
280 282 def scan_changesets_for_issue_ids
281 283 self.changesets.each(&:scan_comment_for_issue_ids)
282 284 end
283 285
284 286 # Returns an array of committers usernames and associated user_id
285 287 def committers
286 288 @committers ||= Changeset.where(:repository_id => id).distinct.pluck(:committer, :user_id)
287 289 end
288 290
289 291 # Maps committers username to a user ids
290 292 def committer_ids=(h)
291 293 if h.is_a?(Hash)
292 294 committers.each do |committer, user_id|
293 295 new_user_id = h[committer]
294 296 if new_user_id && (new_user_id.to_i != user_id.to_i)
295 297 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
296 298 Changeset.where(["repository_id = ? AND committer = ?", id, committer]).
297 299 update_all("user_id = #{new_user_id.nil? ? 'NULL' : new_user_id}")
298 300 end
299 301 end
300 302 @committers = nil
301 303 @found_committer_users = nil
302 304 true
303 305 else
304 306 false
305 307 end
306 308 end
307 309
308 310 # Returns the Redmine User corresponding to the given +committer+
309 311 # It will return nil if the committer is not yet mapped and if no User
310 312 # with the same username or email was found
311 313 def find_committer_user(committer)
312 314 unless committer.blank?
313 315 @found_committer_users ||= {}
314 316 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
315 317
316 318 user = nil
317 319 c = changesets.where(:committer => committer).
318 320 includes(:user).references(:user).first
319 321 if c && c.user
320 322 user = c.user
321 323 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
322 324 username, email = $1.strip, $3
323 325 u = User.find_by_login(username)
324 326 u ||= User.find_by_mail(email) unless email.blank?
325 327 user = u
326 328 end
327 329 @found_committer_users[committer] = user
328 330 user
329 331 end
330 332 end
331 333
332 334 def repo_log_encoding
333 335 encoding = log_encoding.to_s.strip
334 336 encoding.blank? ? 'UTF-8' : encoding
335 337 end
336 338
337 339 # Fetches new changesets for all repositories of active projects
338 340 # Can be called periodically by an external script
339 341 # eg. ruby script/runner "Repository.fetch_changesets"
340 342 def self.fetch_changesets
341 343 Project.active.has_module(:repository).all.each do |project|
342 344 project.repositories.each do |repository|
343 345 begin
344 346 repository.fetch_changesets
345 347 rescue Redmine::Scm::Adapters::CommandFailed => e
346 348 logger.error "scm: error during fetching changesets: #{e.message}"
347 349 end
348 350 end
349 351 end
350 352 end
351 353
352 354 # scan changeset comments to find related and fixed issues for all repositories
353 355 def self.scan_changesets_for_issue_ids
354 356 all.each(&:scan_changesets_for_issue_ids)
355 357 end
356 358
357 359 def self.scm_name
358 360 'Abstract'
359 361 end
360 362
361 363 def self.available_scm
362 364 subclasses.collect {|klass| [klass.scm_name, klass.name]}
363 365 end
364 366
365 367 def self.factory(klass_name, *args)
366 368 repository_class(klass_name).new(*args) rescue nil
367 369 end
368 370
369 371 def self.repository_class(class_name)
370 372 class_name = class_name.to_s.camelize
371 373 if Redmine::Scm::Base.all.include?(class_name)
372 374 "Repository::#{class_name}".constantize
373 375 end
374 376 end
375 377
376 378 def self.scm_adapter_class
377 379 nil
378 380 end
379 381
380 382 def self.scm_command
381 383 ret = ""
382 384 begin
383 385 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
384 386 rescue Exception => e
385 387 logger.error "scm: error during get command: #{e.message}"
386 388 end
387 389 ret
388 390 end
389 391
390 392 def self.scm_version_string
391 393 ret = ""
392 394 begin
393 395 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
394 396 rescue Exception => e
395 397 logger.error "scm: error during get version string: #{e.message}"
396 398 end
397 399 ret
398 400 end
399 401
400 402 def self.scm_available
401 403 ret = false
402 404 begin
403 405 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
404 406 rescue Exception => e
405 407 logger.error "scm: error during get scm available: #{e.message}"
406 408 end
407 409 ret
408 410 end
409 411
410 412 def set_as_default?
411 413 new_record? && project && Repository.where(:project_id => project.id).empty?
412 414 end
413 415
414 416 # Returns a hash with statistics by author in the following form:
415 417 # {
416 418 # "John Smith" => { :commits => 45, :changes => 324 },
417 419 # "Bob" => { ... }
418 420 # }
419 421 #
420 422 # Notes:
421 423 # - this hash honnors the users mapping defined for the repository
422 424 def stats_by_author
423 425 commits = Changeset.where("repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
424 426
425 427 #TODO: restore ordering ; this line probably never worked
426 428 #commits.to_a.sort! {|x, y| x.last <=> y.last}
427 429
428 430 changes = Change.joins(:changeset).where("#{Changeset.table_name}.repository_id = ?", id).select("committer, user_id, count(*) as count").group("committer, user_id")
429 431
430 432 user_ids = changesets.map(&:user_id).compact.uniq
431 433 authors_names = User.where(:id => user_ids).inject({}) do |memo, user|
432 434 memo[user.id] = user.to_s
433 435 memo
434 436 end
435 437
436 438 (commits + changes).inject({}) do |hash, element|
437 439 mapped_name = element.committer
438 440 if username = authors_names[element.user_id.to_i]
439 441 mapped_name = username
440 442 end
441 443 hash[mapped_name] ||= { :commits_count => 0, :changes_count => 0 }
442 444 if element.is_a?(Changeset)
443 445 hash[mapped_name][:commits_count] += element.count.to_i
444 446 else
445 447 hash[mapped_name][:changes_count] += element.count.to_i
446 448 end
447 449 hash
448 450 end
449 451 end
450 452
451 453 # Returns a scope of changesets that come from the same commit as the given changeset
452 454 # in different repositories that point to the same backend
453 455 def same_commits_in_scope(scope, changeset)
454 456 scope = scope.joins(:repository).where(:repositories => {:url => url, :root_url => root_url, :type => type})
455 457 if changeset.scmid.present?
456 458 scope = scope.where(:scmid => changeset.scmid)
457 459 else
458 460 scope = scope.where(:revision => changeset.revision)
459 461 end
460 462 scope
461 463 end
462 464
463 465 protected
464 466
465 467 # Validates repository url based against an optional regular expression
466 468 # that can be set in the Redmine configuration file.
467 469 def validate_repository_path(attribute=:url)
468 470 regexp = Redmine::Configuration["scm_#{scm_name.to_s.downcase}_path_regexp"]
469 471 if changes[attribute] && regexp.present?
470 472 regexp = regexp.to_s.strip.gsub('%project%') {Regexp.escape(project.try(:identifier).to_s)}
471 473 unless send(attribute).to_s.match(Regexp.new("\\A#{regexp}\\z"))
472 474 errors.add(attribute, :invalid)
473 475 end
474 476 end
475 477 end
476 478
477 479 def normalize_identifier
478 480 self.identifier = identifier.to_s.strip
479 481 end
480 482
481 483 def check_default
482 484 if !is_default? && set_as_default?
483 485 self.is_default = true
484 486 end
485 487 if is_default? && is_default_changed?
486 488 Repository.where(["project_id = ?", project_id]).update_all(["is_default = ?", false])
487 489 end
488 490 end
489 491
490 492 def load_entries_changesets(entries)
491 493 if entries
492 494 entries.each do |entry|
493 495 if entry.lastrev && entry.lastrev.identifier
494 496 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
495 497 end
496 498 end
497 499 end
498 500 end
499 501
500 502 private
501 503
502 504 # Deletes repository data
503 505 def clear_changesets
504 506 cs = Changeset.table_name
505 507 ch = Change.table_name
506 508 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
507 509 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
508 510
509 511 self.class.connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
510 512 self.class.connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
511 513 self.class.connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
512 514 self.class.connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
513 515 end
514 516 end
@@ -1,928 +1,929
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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 # Different ways of displaying/sorting users
24 24 USER_FORMATS = {
25 25 :firstname_lastname => {
26 26 :string => '#{firstname} #{lastname}',
27 27 :order => %w(firstname lastname id),
28 28 :setting_order => 1
29 29 },
30 30 :firstname_lastinitial => {
31 31 :string => '#{firstname} #{lastname.to_s.chars.first}.',
32 32 :order => %w(firstname lastname id),
33 33 :setting_order => 2
34 34 },
35 35 :firstinitial_lastname => {
36 36 :string => '#{firstname.to_s.gsub(/(([[:alpha:]])[[:alpha:]]*\.?)/, \'\2.\')} #{lastname}',
37 37 :order => %w(firstname lastname id),
38 38 :setting_order => 2
39 39 },
40 40 :firstname => {
41 41 :string => '#{firstname}',
42 42 :order => %w(firstname id),
43 43 :setting_order => 3
44 44 },
45 45 :lastname_firstname => {
46 46 :string => '#{lastname} #{firstname}',
47 47 :order => %w(lastname firstname id),
48 48 :setting_order => 4
49 49 },
50 50 :lastnamefirstname => {
51 51 :string => '#{lastname}#{firstname}',
52 52 :order => %w(lastname firstname id),
53 53 :setting_order => 5
54 54 },
55 55 :lastname_comma_firstname => {
56 56 :string => '#{lastname}, #{firstname}',
57 57 :order => %w(lastname firstname id),
58 58 :setting_order => 6
59 59 },
60 60 :lastname => {
61 61 :string => '#{lastname}',
62 62 :order => %w(lastname id),
63 63 :setting_order => 7
64 64 },
65 65 :username => {
66 66 :string => '#{login}',
67 67 :order => %w(login id),
68 68 :setting_order => 8
69 69 },
70 70 }
71 71
72 72 MAIL_NOTIFICATION_OPTIONS = [
73 73 ['all', :label_user_mail_option_all],
74 74 ['selected', :label_user_mail_option_selected],
75 75 ['only_my_events', :label_user_mail_option_only_my_events],
76 76 ['only_assigned', :label_user_mail_option_only_assigned],
77 77 ['only_owner', :label_user_mail_option_only_owner],
78 78 ['none', :label_user_mail_option_none]
79 79 ]
80 80
81 81 has_and_belongs_to_many :groups,
82 82 :join_table => "#{table_name_prefix}groups_users#{table_name_suffix}",
83 83 :after_add => Proc.new {|user, group| group.user_added(user)},
84 84 :after_remove => Proc.new {|user, group| group.user_removed(user)}
85 85 has_many :changesets, :dependent => :nullify
86 86 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
87 87 has_one :rss_token, lambda {where "action='feeds'"}, :class_name => 'Token'
88 88 has_one :api_token, lambda {where "action='api'"}, :class_name => 'Token'
89 89 has_one :email_address, lambda {where :is_default => true}, :autosave => true
90 90 has_many :email_addresses, :dependent => :delete_all
91 91 belongs_to :auth_source
92 92
93 93 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
94 94 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
95 95
96 96 acts_as_customizable
97 97
98 98 attr_accessor :password, :password_confirmation, :generate_password
99 99 attr_accessor :last_before_login_on
100 100 attr_accessor :remote_ip
101 101
102 102 # Prevents unauthorized assignments
103 103 attr_protected :password, :password_confirmation, :hashed_password
104 104
105 105 LOGIN_LENGTH_LIMIT = 60
106 106 MAIL_LENGTH_LIMIT = 60
107 107
108 108 validates_presence_of :login, :firstname, :lastname, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
109 109 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
110 110 # Login must contain letters, numbers, underscores only
111 111 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
112 112 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
113 113 validates_length_of :firstname, :lastname, :maximum => 30
114 validates_length_of :identity_url, maximum: 255
114 115 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
115 116 validate :validate_password_length
116 117 validate do
117 118 if password_confirmation && password != password_confirmation
118 119 errors.add(:password, :confirmation)
119 120 end
120 121 end
121 122
122 123 self.valid_statuses = [STATUS_ACTIVE, STATUS_REGISTERED, STATUS_LOCKED]
123 124
124 125 before_validation :instantiate_email_address
125 126 before_create :set_mail_notification
126 127 before_save :generate_password_if_needed, :update_hashed_password
127 128 before_destroy :remove_references_before_destroy
128 129 after_save :update_notified_project_ids, :destroy_tokens, :deliver_security_notification
129 130 after_destroy :deliver_security_notification
130 131
131 132 scope :in_group, lambda {|group|
132 133 group_id = group.is_a?(Group) ? group.id : group.to_i
133 134 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
134 135 }
135 136 scope :not_in_group, lambda {|group|
136 137 group_id = group.is_a?(Group) ? group.id : group.to_i
137 138 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
138 139 }
139 140 scope :sorted, lambda { order(*User.fields_for_order_statement)}
140 141 scope :having_mail, lambda {|arg|
141 142 addresses = Array.wrap(arg).map {|a| a.to_s.downcase}
142 143 if addresses.any?
143 144 joins(:email_addresses).where("LOWER(#{EmailAddress.table_name}.address) IN (?)", addresses).distinct
144 145 else
145 146 none
146 147 end
147 148 }
148 149
149 150 def set_mail_notification
150 151 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
151 152 true
152 153 end
153 154
154 155 def update_hashed_password
155 156 # update hashed_password if password was set
156 157 if self.password && self.auth_source_id.blank?
157 158 salt_password(password)
158 159 end
159 160 end
160 161
161 162 alias :base_reload :reload
162 163 def reload(*args)
163 164 @name = nil
164 165 @projects_by_role = nil
165 166 @membership_by_project_id = nil
166 167 @notified_projects_ids = nil
167 168 @notified_projects_ids_changed = false
168 169 @builtin_role = nil
169 170 @visible_project_ids = nil
170 171 @managed_roles = nil
171 172 base_reload(*args)
172 173 end
173 174
174 175 def mail
175 176 email_address.try(:address)
176 177 end
177 178
178 179 def mail=(arg)
179 180 email = email_address || build_email_address
180 181 email.address = arg
181 182 end
182 183
183 184 def mail_changed?
184 185 email_address.try(:address_changed?)
185 186 end
186 187
187 188 def mails
188 189 email_addresses.pluck(:address)
189 190 end
190 191
191 192 def self.find_or_initialize_by_identity_url(url)
192 193 user = where(:identity_url => url).first
193 194 unless user
194 195 user = User.new
195 196 user.identity_url = url
196 197 end
197 198 user
198 199 end
199 200
200 201 def identity_url=(url)
201 202 if url.blank?
202 203 write_attribute(:identity_url, '')
203 204 else
204 205 begin
205 206 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
206 207 rescue OpenIdAuthentication::InvalidOpenId
207 208 # Invalid url, don't save
208 209 end
209 210 end
210 211 self.read_attribute(:identity_url)
211 212 end
212 213
213 214 # Returns the user that matches provided login and password, or nil
214 215 def self.try_to_login(login, password, active_only=true)
215 216 login = login.to_s
216 217 password = password.to_s
217 218
218 219 # Make sure no one can sign in with an empty login or password
219 220 return nil if login.empty? || password.empty?
220 221 user = find_by_login(login)
221 222 if user
222 223 # user is already in local database
223 224 return nil unless user.check_password?(password)
224 225 return nil if !user.active? && active_only
225 226 else
226 227 # user is not yet registered, try to authenticate with available sources
227 228 attrs = AuthSource.authenticate(login, password)
228 229 if attrs
229 230 user = new(attrs)
230 231 user.login = login
231 232 user.language = Setting.default_language
232 233 if user.save
233 234 user.reload
234 235 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
235 236 end
236 237 end
237 238 end
238 239 user.update_column(:last_login_on, Time.now) if user && !user.new_record? && user.active?
239 240 user
240 241 rescue => text
241 242 raise text
242 243 end
243 244
244 245 # Returns the user who matches the given autologin +key+ or nil
245 246 def self.try_to_autologin(key)
246 247 user = Token.find_active_user('autologin', key, Setting.autologin.to_i)
247 248 if user
248 249 user.update_column(:last_login_on, Time.now)
249 250 user
250 251 end
251 252 end
252 253
253 254 def self.name_formatter(formatter = nil)
254 255 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
255 256 end
256 257
257 258 # Returns an array of fields names than can be used to make an order statement for users
258 259 # according to how user names are displayed
259 260 # Examples:
260 261 #
261 262 # User.fields_for_order_statement => ['users.login', 'users.id']
262 263 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
263 264 def self.fields_for_order_statement(table=nil)
264 265 table ||= table_name
265 266 name_formatter[:order].map {|field| "#{table}.#{field}"}
266 267 end
267 268
268 269 # Return user's full name for display
269 270 def name(formatter = nil)
270 271 f = self.class.name_formatter(formatter)
271 272 if formatter
272 273 eval('"' + f[:string] + '"')
273 274 else
274 275 @name ||= eval('"' + f[:string] + '"')
275 276 end
276 277 end
277 278
278 279 def active?
279 280 self.status == STATUS_ACTIVE
280 281 end
281 282
282 283 def registered?
283 284 self.status == STATUS_REGISTERED
284 285 end
285 286
286 287 def locked?
287 288 self.status == STATUS_LOCKED
288 289 end
289 290
290 291 def activate
291 292 self.status = STATUS_ACTIVE
292 293 end
293 294
294 295 def register
295 296 self.status = STATUS_REGISTERED
296 297 end
297 298
298 299 def lock
299 300 self.status = STATUS_LOCKED
300 301 end
301 302
302 303 def activate!
303 304 update_attribute(:status, STATUS_ACTIVE)
304 305 end
305 306
306 307 def register!
307 308 update_attribute(:status, STATUS_REGISTERED)
308 309 end
309 310
310 311 def lock!
311 312 update_attribute(:status, STATUS_LOCKED)
312 313 end
313 314
314 315 # Returns true if +clear_password+ is the correct user's password, otherwise false
315 316 def check_password?(clear_password)
316 317 if auth_source_id.present?
317 318 auth_source.authenticate(self.login, clear_password)
318 319 else
319 320 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
320 321 end
321 322 end
322 323
323 324 # Generates a random salt and computes hashed_password for +clear_password+
324 325 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
325 326 def salt_password(clear_password)
326 327 self.salt = User.generate_salt
327 328 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
328 329 self.passwd_changed_on = Time.now.change(:usec => 0)
329 330 end
330 331
331 332 # Does the backend storage allow this user to change their password?
332 333 def change_password_allowed?
333 334 return true if auth_source.nil?
334 335 return auth_source.allow_password_changes?
335 336 end
336 337
337 338 # Returns true if the user password has expired
338 339 def password_expired?
339 340 period = Setting.password_max_age.to_i
340 341 if period.zero?
341 342 false
342 343 else
343 344 changed_on = self.passwd_changed_on || Time.at(0)
344 345 changed_on < period.days.ago
345 346 end
346 347 end
347 348
348 349 def must_change_password?
349 350 (must_change_passwd? || password_expired?) && change_password_allowed?
350 351 end
351 352
352 353 def generate_password?
353 354 generate_password == '1' || generate_password == true
354 355 end
355 356
356 357 # Generate and set a random password on given length
357 358 def random_password(length=40)
358 359 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
359 360 chars -= %w(0 O 1 l)
360 361 password = ''
361 362 length.times {|i| password << chars[SecureRandom.random_number(chars.size)] }
362 363 self.password = password
363 364 self.password_confirmation = password
364 365 self
365 366 end
366 367
367 368 def pref
368 369 self.preference ||= UserPreference.new(:user => self)
369 370 end
370 371
371 372 def time_zone
372 373 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
373 374 end
374 375
375 376 def force_default_language?
376 377 Setting.force_default_language_for_loggedin?
377 378 end
378 379
379 380 def language
380 381 if force_default_language?
381 382 Setting.default_language
382 383 else
383 384 super
384 385 end
385 386 end
386 387
387 388 def wants_comments_in_reverse_order?
388 389 self.pref[:comments_sorting] == 'desc'
389 390 end
390 391
391 392 # Return user's RSS key (a 40 chars long string), used to access feeds
392 393 def rss_key
393 394 if rss_token.nil?
394 395 create_rss_token(:action => 'feeds')
395 396 end
396 397 rss_token.value
397 398 end
398 399
399 400 # Return user's API key (a 40 chars long string), used to access the API
400 401 def api_key
401 402 if api_token.nil?
402 403 create_api_token(:action => 'api')
403 404 end
404 405 api_token.value
405 406 end
406 407
407 408 # Generates a new session token and returns its value
408 409 def generate_session_token
409 410 token = Token.create!(:user_id => id, :action => 'session')
410 411 token.value
411 412 end
412 413
413 414 # Returns true if token is a valid session token for the user whose id is user_id
414 415 def self.verify_session_token(user_id, token)
415 416 return false if user_id.blank? || token.blank?
416 417
417 418 scope = Token.where(:user_id => user_id, :value => token.to_s, :action => 'session')
418 419 if Setting.session_lifetime?
419 420 scope = scope.where("created_on > ?", Setting.session_lifetime.to_i.minutes.ago)
420 421 end
421 422 if Setting.session_timeout?
422 423 scope = scope.where("updated_on > ?", Setting.session_timeout.to_i.minutes.ago)
423 424 end
424 425 scope.update_all(:updated_on => Time.now) == 1
425 426 end
426 427
427 428 # Return an array of project ids for which the user has explicitly turned mail notifications on
428 429 def notified_projects_ids
429 430 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
430 431 end
431 432
432 433 def notified_project_ids=(ids)
433 434 @notified_projects_ids_changed = true
434 435 @notified_projects_ids = ids.map(&:to_i).uniq.select {|n| n > 0}
435 436 end
436 437
437 438 # Updates per project notifications (after_save callback)
438 439 def update_notified_project_ids
439 440 if @notified_projects_ids_changed
440 441 ids = (mail_notification == 'selected' ? Array.wrap(notified_projects_ids).reject(&:blank?) : [])
441 442 members.update_all(:mail_notification => false)
442 443 members.where(:project_id => ids).update_all(:mail_notification => true) if ids.any?
443 444 end
444 445 end
445 446 private :update_notified_project_ids
446 447
447 448 def valid_notification_options
448 449 self.class.valid_notification_options(self)
449 450 end
450 451
451 452 # Only users that belong to more than 1 project can select projects for which they are notified
452 453 def self.valid_notification_options(user=nil)
453 454 # Note that @user.membership.size would fail since AR ignores
454 455 # :include association option when doing a count
455 456 if user.nil? || user.memberships.length < 1
456 457 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
457 458 else
458 459 MAIL_NOTIFICATION_OPTIONS
459 460 end
460 461 end
461 462
462 463 # Find a user account by matching the exact login and then a case-insensitive
463 464 # version. Exact matches will be given priority.
464 465 def self.find_by_login(login)
465 466 login = Redmine::CodesetUtil.replace_invalid_utf8(login.to_s)
466 467 if login.present?
467 468 # First look for an exact match
468 469 user = where(:login => login).detect {|u| u.login == login}
469 470 unless user
470 471 # Fail over to case-insensitive if none was found
471 472 user = where("LOWER(login) = ?", login.downcase).first
472 473 end
473 474 user
474 475 end
475 476 end
476 477
477 478 def self.find_by_rss_key(key)
478 479 Token.find_active_user('feeds', key)
479 480 end
480 481
481 482 def self.find_by_api_key(key)
482 483 Token.find_active_user('api', key)
483 484 end
484 485
485 486 # Makes find_by_mail case-insensitive
486 487 def self.find_by_mail(mail)
487 488 having_mail(mail).first
488 489 end
489 490
490 491 # Returns true if the default admin account can no longer be used
491 492 def self.default_admin_account_changed?
492 493 !User.active.find_by_login("admin").try(:check_password?, "admin")
493 494 end
494 495
495 496 def to_s
496 497 name
497 498 end
498 499
499 500 CSS_CLASS_BY_STATUS = {
500 501 STATUS_ANONYMOUS => 'anon',
501 502 STATUS_ACTIVE => 'active',
502 503 STATUS_REGISTERED => 'registered',
503 504 STATUS_LOCKED => 'locked'
504 505 }
505 506
506 507 def css_classes
507 508 "user #{CSS_CLASS_BY_STATUS[status]}"
508 509 end
509 510
510 511 # Returns the current day according to user's time zone
511 512 def today
512 513 if time_zone.nil?
513 514 Date.today
514 515 else
515 516 time_zone.today
516 517 end
517 518 end
518 519
519 520 # Returns the day of +time+ according to user's time zone
520 521 def time_to_date(time)
521 522 if time_zone.nil?
522 523 time.to_date
523 524 else
524 525 time.in_time_zone(time_zone).to_date
525 526 end
526 527 end
527 528
528 529 def logged?
529 530 true
530 531 end
531 532
532 533 def anonymous?
533 534 !logged?
534 535 end
535 536
536 537 # Returns user's membership for the given project
537 538 # or nil if the user is not a member of project
538 539 def membership(project)
539 540 project_id = project.is_a?(Project) ? project.id : project
540 541
541 542 @membership_by_project_id ||= Hash.new {|h, project_id|
542 543 h[project_id] = memberships.where(:project_id => project_id).first
543 544 }
544 545 @membership_by_project_id[project_id]
545 546 end
546 547
547 548 # Returns the user's bult-in role
548 549 def builtin_role
549 550 @builtin_role ||= Role.non_member
550 551 end
551 552
552 553 # Return user's roles for project
553 554 def roles_for_project(project)
554 555 # No role on archived projects
555 556 return [] if project.nil? || project.archived?
556 557 if membership = membership(project)
557 558 membership.roles.to_a
558 559 elsif project.is_public?
559 560 project.override_roles(builtin_role)
560 561 else
561 562 []
562 563 end
563 564 end
564 565
565 566 # Returns a hash of user's projects grouped by roles
566 567 def projects_by_role
567 568 return @projects_by_role if @projects_by_role
568 569
569 570 hash = Hash.new([])
570 571
571 572 group_class = anonymous? ? GroupAnonymous : GroupNonMember
572 573 members = Member.joins(:project, :principal).
573 574 where("#{Project.table_name}.status <> 9").
574 575 where("#{Member.table_name}.user_id = ? OR (#{Project.table_name}.is_public = ? AND #{Principal.table_name}.type = ?)", self.id, true, group_class.name).
575 576 preload(:project, :roles).
576 577 to_a
577 578
578 579 members.reject! {|member| member.user_id != id && project_ids.include?(member.project_id)}
579 580 members.each do |member|
580 581 if member.project
581 582 member.roles.each do |role|
582 583 hash[role] = [] unless hash.key?(role)
583 584 hash[role] << member.project
584 585 end
585 586 end
586 587 end
587 588
588 589 hash.each do |role, projects|
589 590 projects.uniq!
590 591 end
591 592
592 593 @projects_by_role = hash
593 594 end
594 595
595 596 # Returns the ids of visible projects
596 597 def visible_project_ids
597 598 @visible_project_ids ||= Project.visible(self).pluck(:id)
598 599 end
599 600
600 601 # Returns the roles that the user is allowed to manage for the given project
601 602 def managed_roles(project)
602 603 if admin?
603 604 @managed_roles ||= Role.givable.to_a
604 605 else
605 606 membership(project).try(:managed_roles) || []
606 607 end
607 608 end
608 609
609 610 # Returns true if user is arg or belongs to arg
610 611 def is_or_belongs_to?(arg)
611 612 if arg.is_a?(User)
612 613 self == arg
613 614 elsif arg.is_a?(Group)
614 615 arg.users.include?(self)
615 616 else
616 617 false
617 618 end
618 619 end
619 620
620 621 # Return true if the user is allowed to do the specified action on a specific context
621 622 # Action can be:
622 623 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
623 624 # * a permission Symbol (eg. :edit_project)
624 625 # Context can be:
625 626 # * a project : returns true if user is allowed to do the specified action on this project
626 627 # * an array of projects : returns true if user is allowed on every project
627 628 # * nil with options[:global] set : check if user has at least one role allowed for this action,
628 629 # or falls back to Non Member / Anonymous permissions depending if the user is logged
629 630 def allowed_to?(action, context, options={}, &block)
630 631 if context && context.is_a?(Project)
631 632 return false unless context.allows_to?(action)
632 633 # Admin users are authorized for anything else
633 634 return true if admin?
634 635
635 636 roles = roles_for_project(context)
636 637 return false unless roles
637 638 roles.any? {|role|
638 639 (context.is_public? || role.member?) &&
639 640 role.allowed_to?(action) &&
640 641 (block_given? ? yield(role, self) : true)
641 642 }
642 643 elsif context && context.is_a?(Array)
643 644 if context.empty?
644 645 false
645 646 else
646 647 # Authorize if user is authorized on every element of the array
647 648 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
648 649 end
649 650 elsif context
650 651 raise ArgumentError.new("#allowed_to? context argument must be a Project, an Array of projects or nil")
651 652 elsif options[:global]
652 653 # Admin users are always authorized
653 654 return true if admin?
654 655
655 656 # authorize if user has at least one role that has this permission
656 657 roles = memberships.collect {|m| m.roles}.flatten.uniq
657 658 roles << (self.logged? ? Role.non_member : Role.anonymous)
658 659 roles.any? {|role|
659 660 role.allowed_to?(action) &&
660 661 (block_given? ? yield(role, self) : true)
661 662 }
662 663 else
663 664 false
664 665 end
665 666 end
666 667
667 668 # Is the user allowed to do the specified action on any project?
668 669 # See allowed_to? for the actions and valid options.
669 670 #
670 671 # NB: this method is not used anywhere in the core codebase as of
671 672 # 2.5.2, but it's used by many plugins so if we ever want to remove
672 673 # it it has to be carefully deprecated for a version or two.
673 674 def allowed_to_globally?(action, options={}, &block)
674 675 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
675 676 end
676 677
677 678 def allowed_to_view_all_time_entries?(context)
678 679 allowed_to?(:view_time_entries, context) do |role, user|
679 680 role.time_entries_visibility == 'all'
680 681 end
681 682 end
682 683
683 684 # Returns true if the user is allowed to delete the user's own account
684 685 def own_account_deletable?
685 686 Setting.unsubscribe? &&
686 687 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
687 688 end
688 689
689 690 safe_attributes 'firstname',
690 691 'lastname',
691 692 'mail',
692 693 'mail_notification',
693 694 'notified_project_ids',
694 695 'language',
695 696 'custom_field_values',
696 697 'custom_fields',
697 698 'identity_url'
698 699
699 700 safe_attributes 'login',
700 701 :if => lambda {|user, current_user| user.new_record?}
701 702
702 703 safe_attributes 'status',
703 704 'auth_source_id',
704 705 'generate_password',
705 706 'must_change_passwd',
706 707 'login',
707 708 'admin',
708 709 :if => lambda {|user, current_user| current_user.admin?}
709 710
710 711 safe_attributes 'group_ids',
711 712 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
712 713
713 714 # Utility method to help check if a user should be notified about an
714 715 # event.
715 716 #
716 717 # TODO: only supports Issue events currently
717 718 def notify_about?(object)
718 719 if mail_notification == 'all'
719 720 true
720 721 elsif mail_notification.blank? || mail_notification == 'none'
721 722 false
722 723 else
723 724 case object
724 725 when Issue
725 726 case mail_notification
726 727 when 'selected', 'only_my_events'
727 728 # user receives notifications for created/assigned issues on unselected projects
728 729 object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
729 730 when 'only_assigned'
730 731 is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was)
731 732 when 'only_owner'
732 733 object.author == self
733 734 end
734 735 when News
735 736 # always send to project members except when mail_notification is set to 'none'
736 737 true
737 738 end
738 739 end
739 740 end
740 741
741 742 def self.current=(user)
742 743 RequestStore.store[:current_user] = user
743 744 end
744 745
745 746 def self.current
746 747 RequestStore.store[:current_user] ||= User.anonymous
747 748 end
748 749
749 750 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
750 751 # one anonymous user per database.
751 752 def self.anonymous
752 753 anonymous_user = AnonymousUser.first
753 754 if anonymous_user.nil?
754 755 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :login => '', :status => 0)
755 756 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
756 757 end
757 758 anonymous_user
758 759 end
759 760
760 761 # Salts all existing unsalted passwords
761 762 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
762 763 # This method is used in the SaltPasswords migration and is to be kept as is
763 764 def self.salt_unsalted_passwords!
764 765 transaction do
765 766 User.where("salt IS NULL OR salt = ''").find_each do |user|
766 767 next if user.hashed_password.blank?
767 768 salt = User.generate_salt
768 769 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
769 770 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
770 771 end
771 772 end
772 773 end
773 774
774 775 protected
775 776
776 777 def validate_password_length
777 778 return if password.blank? && generate_password?
778 779 # Password length validation based on setting
779 780 if !password.nil? && password.size < Setting.password_min_length.to_i
780 781 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
781 782 end
782 783 end
783 784
784 785 def instantiate_email_address
785 786 email_address || build_email_address
786 787 end
787 788
788 789 private
789 790
790 791 def generate_password_if_needed
791 792 if generate_password? && auth_source.nil?
792 793 length = [Setting.password_min_length.to_i + 2, 10].max
793 794 random_password(length)
794 795 end
795 796 end
796 797
797 798 # Delete all outstanding password reset tokens on password change.
798 799 # Delete the autologin tokens on password change to prohibit session leakage.
799 800 # This helps to keep the account secure in case the associated email account
800 801 # was compromised.
801 802 def destroy_tokens
802 803 if hashed_password_changed? || (status_changed? && !active?)
803 804 tokens = ['recovery', 'autologin', 'session']
804 805 Token.where(:user_id => id, :action => tokens).delete_all
805 806 end
806 807 end
807 808
808 809 # Removes references that are not handled by associations
809 810 # Things that are not deleted are reassociated with the anonymous user
810 811 def remove_references_before_destroy
811 812 return if self.id.nil?
812 813
813 814 substitute = User.anonymous
814 815 Attachment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
815 816 Comment.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
816 817 Issue.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
817 818 Issue.where(['assigned_to_id = ?', id]).update_all('assigned_to_id = NULL')
818 819 Journal.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
819 820 JournalDetail.
820 821 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]).
821 822 update_all(['old_value = ?', substitute.id.to_s])
822 823 JournalDetail.
823 824 where(["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]).
824 825 update_all(['value = ?', substitute.id.to_s])
825 826 Message.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
826 827 News.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
827 828 # Remove private queries and keep public ones
828 829 ::Query.where('user_id = ? AND visibility = ?', id, ::Query::VISIBILITY_PRIVATE).delete_all
829 830 ::Query.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
830 831 TimeEntry.where(['user_id = ?', id]).update_all(['user_id = ?', substitute.id])
831 832 Token.where('user_id = ?', id).delete_all
832 833 Watcher.where('user_id = ?', id).delete_all
833 834 WikiContent.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
834 835 WikiContent::Version.where(['author_id = ?', id]).update_all(['author_id = ?', substitute.id])
835 836 end
836 837
837 838 # Return password digest
838 839 def self.hash_password(clear_password)
839 840 Digest::SHA1.hexdigest(clear_password || "")
840 841 end
841 842
842 843 # Returns a 128bits random salt as a hex string (32 chars long)
843 844 def self.generate_salt
844 845 Redmine::Utils.random_hex(16)
845 846 end
846 847
847 848 # Send a security notification to all admins if the user has gained/lost admin privileges
848 849 def deliver_security_notification
849 850 options = {
850 851 field: :field_admin,
851 852 value: login,
852 853 title: :label_user_plural,
853 854 url: {controller: 'users', action: 'index'}
854 855 }
855 856
856 857 deliver = false
857 858 if (admin? && id_changed? && active?) || # newly created admin
858 859 (admin? && admin_changed? && active?) || # regular user became admin
859 860 (admin? && status_changed? && active?) # locked admin became active again
860 861
861 862 deliver = true
862 863 options[:message] = :mail_body_security_notification_add
863 864
864 865 elsif (admin? && destroyed? && active?) || # active admin user was deleted
865 866 (!admin? && admin_changed? && active?) || # admin is no longer admin
866 867 (admin? && status_changed? && !active?) # admin was locked
867 868
868 869 deliver = true
869 870 options[:message] = :mail_body_security_notification_remove
870 871 end
871 872
872 873 if deliver
873 874 users = User.active.where(admin: true).to_a
874 875 Mailer.security_notification(users, options).deliver
875 876 end
876 877 end
877 878 end
878 879
879 880 class AnonymousUser < User
880 881 validate :validate_anonymous_uniqueness, :on => :create
881 882
882 883 self.valid_statuses = [STATUS_ANONYMOUS]
883 884
884 885 def validate_anonymous_uniqueness
885 886 # There should be only one AnonymousUser in the database
886 887 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
887 888 end
888 889
889 890 def available_custom_fields
890 891 []
891 892 end
892 893
893 894 # Overrides a few properties
894 895 def logged?; false end
895 896 def admin; false end
896 897 def name(*args); I18n.t(:label_user_anonymous) end
897 898 def mail=(*args); nil end
898 899 def mail; nil end
899 900 def time_zone; nil end
900 901 def rss_key; nil end
901 902
902 903 def pref
903 904 UserPreference.new(:user => self)
904 905 end
905 906
906 907 # Returns the user's bult-in role
907 908 def builtin_role
908 909 @builtin_role ||= Role.anonymous
909 910 end
910 911
911 912 def membership(*args)
912 913 nil
913 914 end
914 915
915 916 def member_of?(*args)
916 917 false
917 918 end
918 919
919 920 # Anonymous user can not be destroyed
920 921 def destroy
921 922 false
922 923 end
923 924
924 925 protected
925 926
926 927 def instantiate_email_address
927 928 end
928 929 end
@@ -1,362 +1,362
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Version < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 after_update :update_issues_from_sharing_change
22 22 after_save :update_default_project_version
23 23 before_destroy :nullify_projects_default_version
24 24
25 25 belongs_to :project
26 26 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
27 27 acts_as_customizable
28 28 acts_as_attachable :view_permission => :view_files,
29 29 :edit_permission => :manage_files,
30 30 :delete_permission => :manage_files
31 31
32 32 VERSION_STATUSES = %w(open locked closed)
33 33 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
34 34
35 35 validates_presence_of :name
36 36 validates_uniqueness_of :name, :scope => [:project_id]
37 37 validates_length_of :name, :maximum => 60
38 validates_length_of :description, :maximum => 255
38 validates_length_of :description, :wiki_page_title, :maximum => 255
39 39 validates :effective_date, :date => true
40 40 validates_inclusion_of :status, :in => VERSION_STATUSES
41 41 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
42 42 attr_protected :id
43 43
44 44 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
45 45 scope :like, lambda {|arg|
46 46 if arg.present?
47 47 pattern = "%#{arg.to_s.strip}%"
48 48 where("LOWER(#{Version.table_name}.name) LIKE :p", :p => pattern)
49 49 end
50 50 }
51 51 scope :open, lambda { where(:status => 'open') }
52 52 scope :status, lambda {|status|
53 53 if status.present?
54 54 where(:status => status.to_s)
55 55 end
56 56 }
57 57 scope :visible, lambda {|*args|
58 58 joins(:project).
59 59 where(Project.allowed_to_condition(args.first || User.current, :view_issues))
60 60 }
61 61
62 62 safe_attributes 'name',
63 63 'description',
64 64 'effective_date',
65 65 'due_date',
66 66 'wiki_page_title',
67 67 'status',
68 68 'sharing',
69 69 'default_project_version',
70 70 'custom_field_values',
71 71 'custom_fields'
72 72
73 73 # Returns true if +user+ or current user is allowed to view the version
74 74 def visible?(user=User.current)
75 75 user.allowed_to?(:view_issues, self.project)
76 76 end
77 77
78 78 # Version files have same visibility as project files
79 79 def attachments_visible?(*args)
80 80 project.present? && project.attachments_visible?(*args)
81 81 end
82 82
83 83 def attachments_deletable?(usr=User.current)
84 84 project.present? && project.attachments_deletable?(usr)
85 85 end
86 86
87 87 alias :base_reload :reload
88 88 def reload(*args)
89 89 @default_project_version = nil
90 90 base_reload(*args)
91 91 end
92 92
93 93 def start_date
94 94 @start_date ||= fixed_issues.minimum('start_date')
95 95 end
96 96
97 97 def due_date
98 98 effective_date
99 99 end
100 100
101 101 def due_date=(arg)
102 102 self.effective_date=(arg)
103 103 end
104 104
105 105 # Returns the total estimated time for this version
106 106 # (sum of leaves estimated_hours)
107 107 def estimated_hours
108 108 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
109 109 end
110 110
111 111 # Returns the total reported time for this version
112 112 def spent_hours
113 113 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
114 114 end
115 115
116 116 def closed?
117 117 status == 'closed'
118 118 end
119 119
120 120 def open?
121 121 status == 'open'
122 122 end
123 123
124 124 # Returns true if the version is completed: closed or due date reached and no open issues
125 125 def completed?
126 126 closed? || (effective_date && (effective_date < User.current.today) && (open_issues_count == 0))
127 127 end
128 128
129 129 def behind_schedule?
130 130 if completed_percent == 100
131 131 return false
132 132 elsif due_date && start_date
133 133 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
134 134 return done_date <= User.current.today
135 135 else
136 136 false # No issues so it's not late
137 137 end
138 138 end
139 139
140 140 # Returns the completion percentage of this version based on the amount of open/closed issues
141 141 # and the time spent on the open issues.
142 142 def completed_percent
143 143 if issues_count == 0
144 144 0
145 145 elsif open_issues_count == 0
146 146 100
147 147 else
148 148 issues_progress(false) + issues_progress(true)
149 149 end
150 150 end
151 151
152 152 # Returns the percentage of issues that have been marked as 'closed'.
153 153 def closed_percent
154 154 if issues_count == 0
155 155 0
156 156 else
157 157 issues_progress(false)
158 158 end
159 159 end
160 160
161 161 # Returns true if the version is overdue: due date reached and some open issues
162 162 def overdue?
163 163 effective_date && (effective_date < User.current.today) && (open_issues_count > 0)
164 164 end
165 165
166 166 # Returns assigned issues count
167 167 def issues_count
168 168 load_issue_counts
169 169 @issue_count
170 170 end
171 171
172 172 # Returns the total amount of open issues for this version.
173 173 def open_issues_count
174 174 load_issue_counts
175 175 @open_issues_count
176 176 end
177 177
178 178 # Returns the total amount of closed issues for this version.
179 179 def closed_issues_count
180 180 load_issue_counts
181 181 @closed_issues_count
182 182 end
183 183
184 184 def wiki_page
185 185 if project.wiki && !wiki_page_title.blank?
186 186 @wiki_page ||= project.wiki.find_page(wiki_page_title)
187 187 end
188 188 @wiki_page
189 189 end
190 190
191 191 def to_s; name end
192 192
193 193 def to_s_with_project
194 194 "#{project} - #{name}"
195 195 end
196 196
197 197 # Versions are sorted by effective_date and name
198 198 # Those with no effective_date are at the end, sorted by name
199 199 def <=>(version)
200 200 if self.effective_date
201 201 if version.effective_date
202 202 if self.effective_date == version.effective_date
203 203 name == version.name ? id <=> version.id : name <=> version.name
204 204 else
205 205 self.effective_date <=> version.effective_date
206 206 end
207 207 else
208 208 -1
209 209 end
210 210 else
211 211 if version.effective_date
212 212 1
213 213 else
214 214 name == version.name ? id <=> version.id : name <=> version.name
215 215 end
216 216 end
217 217 end
218 218
219 219 # Sort versions by status (open, locked then closed versions)
220 220 def self.sort_by_status(versions)
221 221 versions.sort do |a, b|
222 222 if a.status == b.status
223 223 a <=> b
224 224 else
225 225 b.status <=> a.status
226 226 end
227 227 end
228 228 end
229 229
230 230 def css_classes
231 231 [
232 232 completed? ? 'version-completed' : 'version-incompleted',
233 233 "version-#{status}"
234 234 ].join(' ')
235 235 end
236 236
237 237 def self.fields_for_order_statement(table=nil)
238 238 table ||= table_name
239 239 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
240 240 end
241 241
242 242 scope :sorted, lambda { order(fields_for_order_statement) }
243 243
244 244 # Returns the sharings that +user+ can set the version to
245 245 def allowed_sharings(user = User.current)
246 246 VERSION_SHARINGS.select do |s|
247 247 if sharing == s
248 248 true
249 249 else
250 250 case s
251 251 when 'system'
252 252 # Only admin users can set a systemwide sharing
253 253 user.admin?
254 254 when 'hierarchy', 'tree'
255 255 # Only users allowed to manage versions of the root project can
256 256 # set sharing to hierarchy or tree
257 257 project.nil? || user.allowed_to?(:manage_versions, project.root)
258 258 else
259 259 true
260 260 end
261 261 end
262 262 end
263 263 end
264 264
265 265 # Returns true if the version is shared, otherwise false
266 266 def shared?
267 267 sharing != 'none'
268 268 end
269 269
270 270 def deletable?
271 271 fixed_issues.empty? && !referenced_by_a_custom_field?
272 272 end
273 273
274 274 def default_project_version
275 275 if @default_project_version.nil?
276 276 project.present? && project.default_version == self
277 277 else
278 278 @default_project_version
279 279 end
280 280 end
281 281
282 282 def default_project_version=(arg)
283 283 @default_project_version = (arg == '1' || arg == true)
284 284 end
285 285
286 286 private
287 287
288 288 def load_issue_counts
289 289 unless @issue_count
290 290 @open_issues_count = 0
291 291 @closed_issues_count = 0
292 292 fixed_issues.group(:status).count.each do |status, count|
293 293 if status.is_closed?
294 294 @closed_issues_count += count
295 295 else
296 296 @open_issues_count += count
297 297 end
298 298 end
299 299 @issue_count = @open_issues_count + @closed_issues_count
300 300 end
301 301 end
302 302
303 303 # Update the issue's fixed versions. Used if a version's sharing changes.
304 304 def update_issues_from_sharing_change
305 305 if sharing_changed?
306 306 if VERSION_SHARINGS.index(sharing_was).nil? ||
307 307 VERSION_SHARINGS.index(sharing).nil? ||
308 308 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
309 309 Issue.update_versions_from_sharing_change self
310 310 end
311 311 end
312 312 end
313 313
314 314 def update_default_project_version
315 315 if @default_project_version && project.present?
316 316 project.update_columns :default_version_id => id
317 317 end
318 318 end
319 319
320 320 # Returns the average estimated time of assigned issues
321 321 # or 1 if no issue has an estimated time
322 322 # Used to weight unestimated issues in progress calculation
323 323 def estimated_average
324 324 if @estimated_average.nil?
325 325 average = fixed_issues.average(:estimated_hours).to_f
326 326 if average == 0
327 327 average = 1
328 328 end
329 329 @estimated_average = average
330 330 end
331 331 @estimated_average
332 332 end
333 333
334 334 # Returns the total progress of open or closed issues. The returned percentage takes into account
335 335 # the amount of estimated time set for this version.
336 336 #
337 337 # Examples:
338 338 # issues_progress(true) => returns the progress percentage for open issues.
339 339 # issues_progress(false) => returns the progress percentage for closed issues.
340 340 def issues_progress(open)
341 341 @issues_progress ||= {}
342 342 @issues_progress[open] ||= begin
343 343 progress = 0
344 344 if issues_count > 0
345 345 ratio = open ? 'done_ratio' : 100
346 346
347 347 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
348 348 progress = done / (estimated_average * issues_count)
349 349 end
350 350 progress
351 351 end
352 352 end
353 353
354 354 def referenced_by_a_custom_field?
355 355 CustomValue.joins(:custom_field).
356 356 where(:value => id.to_s, :custom_fields => {:field_format => 'version'}).any?
357 357 end
358 358
359 359 def nullify_projects_default_version
360 360 Project.where(:default_version_id => id).update_all(:default_version_id => nil)
361 361 end
362 362 end
@@ -1,106 +1,107
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 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 Wiki < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 belongs_to :project
21 21 has_many :pages, lambda {order('title')}, :class_name => 'WikiPage', :dependent => :destroy
22 22 has_many :redirects, :class_name => 'WikiRedirect'
23 23
24 24 acts_as_watchable
25 25
26 26 validates_presence_of :start_page
27 27 validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/
28 validates_length_of :title, maximum: 255
28 29 attr_protected :id
29 30
30 31 before_destroy :delete_redirects
31 32
32 33 safe_attributes 'start_page'
33 34
34 35 def visible?(user=User.current)
35 36 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
36 37 end
37 38
38 39 # Returns the wiki page that acts as the sidebar content
39 40 # or nil if no such page exists
40 41 def sidebar
41 42 @sidebar ||= find_page('Sidebar', :with_redirect => false)
42 43 end
43 44
44 45 # find the page with the given title
45 46 # if page doesn't exist, return a new page
46 47 def find_or_new_page(title)
47 48 title = start_page if title.blank?
48 49 find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title))
49 50 end
50 51
51 52 # find the page with the given title
52 53 def find_page(title, options = {})
53 54 @page_found_with_redirect = false
54 55 title = start_page if title.blank?
55 56 title = Wiki.titleize(title)
56 57 page = pages.where("LOWER(title) = LOWER(?)", title).first
57 58 if page.nil? && options[:with_redirect] != false
58 59 # search for a redirect
59 60 redirect = redirects.where("LOWER(title) = LOWER(?)", title).first
60 61 if redirect
61 62 page = redirect.target_page
62 63 @page_found_with_redirect = true
63 64 end
64 65 end
65 66 page
66 67 end
67 68
68 69 # Returns true if the last page was found with a redirect
69 70 def page_found_with_redirect?
70 71 @page_found_with_redirect
71 72 end
72 73
73 74 # Deletes all redirects from/to the wiki
74 75 def delete_redirects
75 76 WikiRedirect.where(:wiki_id => id).delete_all
76 77 WikiRedirect.where(:redirects_to_wiki_id => id).delete_all
77 78 end
78 79
79 80 # Finds a page by title
80 81 # The given string can be of one of the forms: "title" or "project:title"
81 82 # Examples:
82 83 # Wiki.find_page("bar", project => foo)
83 84 # Wiki.find_page("foo:bar")
84 85 def self.find_page(title, options = {})
85 86 project = options[:project]
86 87 if title.to_s =~ %r{^([^\:]+)\:(.*)$}
87 88 project_identifier, title = $1, $2
88 89 project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier)
89 90 end
90 91 if project && project.wiki
91 92 page = project.wiki.find_page(title)
92 93 if page && page.content
93 94 page
94 95 end
95 96 end
96 97 end
97 98
98 99 # turn a string into a valid page title
99 100 def self.titleize(title)
100 101 # replace spaces with _ and remove unwanted caracters
101 102 title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
102 103 # upcase the first letter
103 104 title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title
104 105 title
105 106 end
106 107 end
@@ -1,297 +1,298
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'diff'
19 19 require 'enumerator'
20 20
21 21 class WikiPage < ActiveRecord::Base
22 22 include Redmine::SafeAttributes
23 23
24 24 belongs_to :wiki
25 25 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
26 26 has_one :content_without_text, lambda {without_text.readonly}, :class_name => 'WikiContent', :foreign_key => 'page_id'
27 27
28 28 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
29 29 acts_as_tree :dependent => :nullify, :order => 'title'
30 30
31 31 acts_as_watchable
32 32 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
33 33 :description => :text,
34 34 :datetime => :created_on,
35 35 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
36 36
37 37 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
38 38 :scope => joins(:content, {:wiki => :project}),
39 39 :preload => [:content, {:wiki => :project}],
40 40 :permission => :view_wiki_pages,
41 41 :project_key => "#{Wiki.table_name}.project_id"
42 42
43 43 attr_accessor :redirect_existing_links
44 44
45 45 validates_presence_of :title
46 46 validates_format_of :title, :with => /\A[^,\.\/\?\;\|\s]*\z/
47 47 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
48 validates_length_of :title, maximum: 255
48 49 validates_associated :content
49 50 attr_protected :id
50 51
51 52 validate :validate_parent_title
52 53 before_destroy :delete_redirects
53 54 before_save :handle_rename_or_move
54 55 after_save :handle_children_move
55 56
56 57 # eager load information about last updates, without loading text
57 58 scope :with_updated_on, lambda { preload(:content_without_text) }
58 59
59 60 # Wiki pages that are protected by default
60 61 DEFAULT_PROTECTED_PAGES = %w(sidebar)
61 62
62 63 safe_attributes 'parent_id', 'parent_title', 'title', 'redirect_existing_links', 'wiki_id',
63 64 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
64 65
65 66 def initialize(attributes=nil, *args)
66 67 super
67 68 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
68 69 self.protected = true
69 70 end
70 71 end
71 72
72 73 def visible?(user=User.current)
73 74 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
74 75 end
75 76
76 77 def title=(value)
77 78 value = Wiki.titleize(value)
78 79 write_attribute(:title, value)
79 80 end
80 81
81 82 def safe_attributes=(attrs, user=User.current)
82 83 return unless attrs.is_a?(Hash)
83 84 attrs = attrs.deep_dup
84 85
85 86 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
86 87 if (w_id = attrs.delete('wiki_id')) && safe_attribute?('wiki_id')
87 88 if (w = Wiki.find_by_id(w_id)) && w.project && user.allowed_to?(:rename_wiki_pages, w.project)
88 89 self.wiki = w
89 90 end
90 91 end
91 92
92 93 super attrs, user
93 94 end
94 95
95 96 # Manages redirects if page is renamed or moved
96 97 def handle_rename_or_move
97 98 if !new_record? && (title_changed? || wiki_id_changed?)
98 99 # Update redirects that point to the old title
99 100 WikiRedirect.where(:redirects_to => title_was, :redirects_to_wiki_id => wiki_id_was).each do |r|
100 101 r.redirects_to = title
101 102 r.redirects_to_wiki_id = wiki_id
102 103 (r.title == r.redirects_to && r.wiki_id == r.redirects_to_wiki_id) ? r.destroy : r.save
103 104 end
104 105 # Remove redirects for the new title
105 106 WikiRedirect.where(:wiki_id => wiki_id, :title => title).delete_all
106 107 # Create a redirect to the new title
107 108 unless redirect_existing_links == "0"
108 109 WikiRedirect.create(
109 110 :wiki_id => wiki_id_was, :title => title_was,
110 111 :redirects_to_wiki_id => wiki_id, :redirects_to => title
111 112 )
112 113 end
113 114 end
114 115 if !new_record? && wiki_id_changed? && parent.present?
115 116 unless parent.wiki_id == wiki_id
116 117 self.parent_id = nil
117 118 end
118 119 end
119 120 end
120 121 private :handle_rename_or_move
121 122
122 123 # Moves child pages if page was moved
123 124 def handle_children_move
124 125 if !new_record? && wiki_id_changed?
125 126 children.each do |child|
126 127 child.wiki_id = wiki_id
127 128 child.redirect_existing_links = redirect_existing_links
128 129 unless child.save
129 130 WikiPage.where(:id => child.id).update_all :parent_id => nil
130 131 end
131 132 end
132 133 end
133 134 end
134 135 private :handle_children_move
135 136
136 137 # Deletes redirects to this page
137 138 def delete_redirects
138 139 WikiRedirect.where(:redirects_to_wiki_id => wiki_id, :redirects_to => title).delete_all
139 140 end
140 141
141 142 def pretty_title
142 143 WikiPage.pretty_title(title)
143 144 end
144 145
145 146 def content_for_version(version=nil)
146 147 if content
147 148 result = content.versions.find_by_version(version.to_i) if version
148 149 result ||= content
149 150 result
150 151 end
151 152 end
152 153
153 154 def diff(version_to=nil, version_from=nil)
154 155 version_to = version_to ? version_to.to_i : self.content.version
155 156 content_to = content.versions.find_by_version(version_to)
156 157 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
157 158 return nil unless content_to && content_from
158 159
159 160 if content_from.version > content_to.version
160 161 content_to, content_from = content_from, content_to
161 162 end
162 163
163 164 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
164 165 end
165 166
166 167 def annotate(version=nil)
167 168 version = version ? version.to_i : self.content.version
168 169 c = content.versions.find_by_version(version)
169 170 c ? WikiAnnotate.new(c) : nil
170 171 end
171 172
172 173 def self.pretty_title(str)
173 174 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
174 175 end
175 176
176 177 def project
177 178 wiki.try(:project)
178 179 end
179 180
180 181 def text
181 182 content.text if content
182 183 end
183 184
184 185 def updated_on
185 186 content_attribute(:updated_on)
186 187 end
187 188
188 189 def version
189 190 content_attribute(:version)
190 191 end
191 192
192 193 # Returns true if usr is allowed to edit the page, otherwise false
193 194 def editable_by?(usr)
194 195 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
195 196 end
196 197
197 198 def attachments_deletable?(usr=User.current)
198 199 editable_by?(usr) && super(usr)
199 200 end
200 201
201 202 def parent_title
202 203 @parent_title || (self.parent && self.parent.pretty_title)
203 204 end
204 205
205 206 def parent_title=(t)
206 207 @parent_title = t
207 208 parent_page = t.blank? ? nil : self.wiki.find_page(t)
208 209 self.parent = parent_page
209 210 end
210 211
211 212 # Saves the page and its content if text was changed
212 213 # Return true if the page was saved
213 214 def save_with_content(content)
214 215 ret = nil
215 216 transaction do
216 217 ret = save
217 218 if content.text_changed?
218 219 begin
219 220 self.content = content
220 221 ret = ret && content.changed?
221 222 rescue ActiveRecord::RecordNotSaved
222 223 ret = false
223 224 end
224 225 end
225 226 raise ActiveRecord::Rollback unless ret
226 227 end
227 228 ret
228 229 end
229 230
230 231 protected
231 232
232 233 def validate_parent_title
233 234 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
234 235 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
235 236 if parent_id_changed? && parent && (parent.wiki_id != wiki_id)
236 237 errors.add(:parent_title, :not_same_project)
237 238 end
238 239 end
239 240
240 241 private
241 242
242 243 def content_attribute(name)
243 244 (association(:content).loaded? ? content : content_without_text).try(name)
244 245 end
245 246 end
246 247
247 248 class WikiDiff < Redmine::Helpers::Diff
248 249 attr_reader :content_to, :content_from
249 250
250 251 def initialize(content_to, content_from)
251 252 @content_to = content_to
252 253 @content_from = content_from
253 254 super(content_to.text, content_from.text)
254 255 end
255 256 end
256 257
257 258 class WikiAnnotate
258 259 attr_reader :lines, :content
259 260
260 261 def initialize(content)
261 262 @content = content
262 263 current = content
263 264 current_lines = current.text.split(/\r?\n/)
264 265 @lines = current_lines.collect {|t| [nil, nil, t]}
265 266 positions = []
266 267 current_lines.size.times {|i| positions << i}
267 268 while (current.previous)
268 269 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
269 270 d.each_slice(3) do |s|
270 271 sign, line = s[0], s[1]
271 272 if sign == '+' && positions[line] && positions[line] != -1
272 273 if @lines[positions[line]][0].nil?
273 274 @lines[positions[line]][0] = current.version
274 275 @lines[positions[line]][1] = current.author
275 276 end
276 277 end
277 278 end
278 279 d.each_slice(3) do |s|
279 280 sign, line = s[0], s[1]
280 281 if sign == '-'
281 282 positions.insert(line, -1)
282 283 else
283 284 positions[line] = nil
284 285 end
285 286 end
286 287 positions.compact!
287 288 # Stop if every line is annotated
288 289 break unless @lines.detect { |line| line[0].nil? }
289 290 current = current.previous
290 291 end
291 292 @lines.each { |line|
292 293 line[0] ||= current.version
293 294 # if the last known version is > 1 (eg. history was cleared), we don't know the author
294 295 line[1] ||= current.author if current.version == 1
295 296 }
296 297 end
297 298 end
General Comments 0
You need to be logged in to leave comments. Login now