##// END OF EJS Templates
Use \A and \z in validation regexps....
Jean-Philippe Lang -
r10733:147e7a8d6172
parent child
Show More
@@ -1,969 +1,969
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_CLOSED = 5
24 24 STATUS_ARCHIVED = 9
25 25
26 26 # Maximum length for project identifiers
27 27 IDENTIFIER_MAX_LENGTH = 100
28 28
29 29 # Specific overidden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members, :include => [:principal, :roles], :conditions => "#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE}"
32 32 has_many :memberships, :class_name => 'Member'
33 33 has_many :member_principals, :class_name => 'Member',
34 34 :include => :principal,
35 35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{Principal::STATUS_ACTIVE})"
36 36 has_many :users, :through => :members
37 37 has_many :principals, :through => :member_principals, :source => :principal
38 38
39 39 has_many :enabled_modules, :dependent => :delete_all
40 40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
41 41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
42 42 has_many :issue_changes, :through => :issues, :source => :journals
43 43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
44 44 has_many :time_entries, :dependent => :delete_all
45 45 has_many :queries, :dependent => :delete_all
46 46 has_many :documents, :dependent => :destroy
47 47 has_many :news, :dependent => :destroy, :include => :author
48 48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
49 49 has_many :boards, :dependent => :destroy, :order => "position ASC"
50 50 has_one :repository, :conditions => ["is_default = ?", true]
51 51 has_many :repositories, :dependent => :destroy
52 52 has_many :changesets, :through => :repository
53 53 has_one :wiki, :dependent => :destroy
54 54 # Custom field for the project issues
55 55 has_and_belongs_to_many :issue_custom_fields,
56 56 :class_name => 'IssueCustomField',
57 57 :order => "#{CustomField.table_name}.position",
58 58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
59 59 :association_foreign_key => 'custom_field_id'
60 60
61 61 acts_as_nested_set :order => 'name', :dependent => :destroy
62 62 acts_as_attachable :view_permission => :view_files,
63 63 :delete_permission => :manage_files
64 64
65 65 acts_as_customizable
66 66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
67 67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
68 68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
69 69 :author => nil
70 70
71 71 attr_protected :status
72 72
73 73 validates_presence_of :name, :identifier
74 74 validates_uniqueness_of :identifier
75 75 validates_associated :repository, :wiki
76 76 validates_length_of :name, :maximum => 255
77 77 validates_length_of :homepage, :maximum => 255
78 78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
79 79 # donwcase letters, digits, dashes but not digits only
80 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
80 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
81 81 # reserved words
82 82 validates_exclusion_of :identifier, :in => %w( new )
83 83
84 84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
85 85 before_destroy :delete_all_members
86 86
87 87 scope :has_module, lambda {|mod|
88 88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 89 }
90 90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 92 scope :all_public, lambda { where(:is_public => true) }
93 93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 94 scope :allowed_to, lambda {|*args|
95 95 user = User.current
96 96 permission = nil
97 97 if args.first.is_a?(Symbol)
98 98 permission = args.shift
99 99 else
100 100 user = args.shift
101 101 permission = args.shift
102 102 end
103 103 where(Project.allowed_to_condition(user, permission, *args))
104 104 }
105 105 scope :like, lambda {|arg|
106 106 if arg.blank?
107 107 where(nil)
108 108 else
109 109 pattern = "%#{arg.to_s.strip.downcase}%"
110 110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 111 end
112 112 }
113 113
114 114 def initialize(attributes=nil, *args)
115 115 super
116 116
117 117 initialized = (attributes || {}).stringify_keys
118 118 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
119 119 self.identifier = Project.next_identifier
120 120 end
121 121 if !initialized.key?('is_public')
122 122 self.is_public = Setting.default_projects_public?
123 123 end
124 124 if !initialized.key?('enabled_module_names')
125 125 self.enabled_module_names = Setting.default_projects_modules
126 126 end
127 127 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
128 128 self.trackers = Tracker.sorted.all
129 129 end
130 130 end
131 131
132 132 def identifier=(identifier)
133 133 super unless identifier_frozen?
134 134 end
135 135
136 136 def identifier_frozen?
137 137 errors[:identifier].blank? && !(new_record? || identifier.blank?)
138 138 end
139 139
140 140 # returns latest created projects
141 141 # non public projects will be returned only if user is a member of those
142 142 def self.latest(user=nil, count=5)
143 143 visible(user).limit(count).order("created_on DESC").all
144 144 end
145 145
146 146 # Returns true if the project is visible to +user+ or to the current user.
147 147 def visible?(user=User.current)
148 148 user.allowed_to?(:view_project, self)
149 149 end
150 150
151 151 # Returns a SQL conditions string used to find all projects visible by the specified user.
152 152 #
153 153 # Examples:
154 154 # Project.visible_condition(admin) => "projects.status = 1"
155 155 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
156 156 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
157 157 def self.visible_condition(user, options={})
158 158 allowed_to_condition(user, :view_project, options)
159 159 end
160 160
161 161 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
162 162 #
163 163 # Valid options:
164 164 # * :project => limit the condition to project
165 165 # * :with_subprojects => limit the condition to project and its subprojects
166 166 # * :member => limit the condition to the user projects
167 167 def self.allowed_to_condition(user, permission, options={})
168 168 perm = Redmine::AccessControl.permission(permission)
169 169 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
170 170 if perm && perm.project_module
171 171 # If the permission belongs to a project module, make sure the module is enabled
172 172 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
173 173 end
174 174 if options[:project]
175 175 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
176 176 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
177 177 base_statement = "(#{project_statement}) AND (#{base_statement})"
178 178 end
179 179
180 180 if user.admin?
181 181 base_statement
182 182 else
183 183 statement_by_role = {}
184 184 unless options[:member]
185 185 role = user.logged? ? Role.non_member : Role.anonymous
186 186 if role.allowed_to?(permission)
187 187 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
188 188 end
189 189 end
190 190 if user.logged?
191 191 user.projects_by_role.each do |role, projects|
192 192 if role.allowed_to?(permission) && projects.any?
193 193 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
194 194 end
195 195 end
196 196 end
197 197 if statement_by_role.empty?
198 198 "1=0"
199 199 else
200 200 if block_given?
201 201 statement_by_role.each do |role, statement|
202 202 if s = yield(role, user)
203 203 statement_by_role[role] = "(#{statement} AND (#{s}))"
204 204 end
205 205 end
206 206 end
207 207 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
208 208 end
209 209 end
210 210 end
211 211
212 212 # Returns the Systemwide and project specific activities
213 213 def activities(include_inactive=false)
214 214 if include_inactive
215 215 return all_activities
216 216 else
217 217 return active_activities
218 218 end
219 219 end
220 220
221 221 # Will create a new Project specific Activity or update an existing one
222 222 #
223 223 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
224 224 # does not successfully save.
225 225 def update_or_create_time_entry_activity(id, activity_hash)
226 226 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
227 227 self.create_time_entry_activity_if_needed(activity_hash)
228 228 else
229 229 activity = project.time_entry_activities.find_by_id(id.to_i)
230 230 activity.update_attributes(activity_hash) if activity
231 231 end
232 232 end
233 233
234 234 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
235 235 #
236 236 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
237 237 # does not successfully save.
238 238 def create_time_entry_activity_if_needed(activity)
239 239 if activity['parent_id']
240 240
241 241 parent_activity = TimeEntryActivity.find(activity['parent_id'])
242 242 activity['name'] = parent_activity.name
243 243 activity['position'] = parent_activity.position
244 244
245 245 if Enumeration.overridding_change?(activity, parent_activity)
246 246 project_activity = self.time_entry_activities.create(activity)
247 247
248 248 if project_activity.new_record?
249 249 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
250 250 else
251 251 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
252 252 end
253 253 end
254 254 end
255 255 end
256 256
257 257 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
258 258 #
259 259 # Examples:
260 260 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
261 261 # project.project_condition(false) => "projects.id = 1"
262 262 def project_condition(with_subprojects)
263 263 cond = "#{Project.table_name}.id = #{id}"
264 264 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
265 265 cond
266 266 end
267 267
268 268 def self.find(*args)
269 269 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
270 270 project = find_by_identifier(*args)
271 271 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
272 272 project
273 273 else
274 274 super
275 275 end
276 276 end
277 277
278 278 def self.find_by_param(*args)
279 279 self.find(*args)
280 280 end
281 281
282 282 def reload(*args)
283 283 @shared_versions = nil
284 284 @rolled_up_versions = nil
285 285 @rolled_up_trackers = nil
286 286 @all_issue_custom_fields = nil
287 287 @all_time_entry_custom_fields = nil
288 288 @to_param = nil
289 289 @allowed_parents = nil
290 290 @allowed_permissions = nil
291 291 @actions_allowed = nil
292 292 super
293 293 end
294 294
295 295 def to_param
296 296 # id is used for projects with a numeric identifier (compatibility)
297 297 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
298 298 end
299 299
300 300 def active?
301 301 self.status == STATUS_ACTIVE
302 302 end
303 303
304 304 def archived?
305 305 self.status == STATUS_ARCHIVED
306 306 end
307 307
308 308 # Archives the project and its descendants
309 309 def archive
310 310 # Check that there is no issue of a non descendant project that is assigned
311 311 # to one of the project or descendant versions
312 312 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
313 313 if v_ids.any? &&
314 314 Issue.
315 315 includes(:project).
316 316 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
317 317 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
318 318 exists?
319 319 return false
320 320 end
321 321 Project.transaction do
322 322 archive!
323 323 end
324 324 true
325 325 end
326 326
327 327 # Unarchives the project
328 328 # All its ancestors must be active
329 329 def unarchive
330 330 return false if ancestors.detect {|a| !a.active?}
331 331 update_attribute :status, STATUS_ACTIVE
332 332 end
333 333
334 334 def close
335 335 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
336 336 end
337 337
338 338 def reopen
339 339 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
340 340 end
341 341
342 342 # Returns an array of projects the project can be moved to
343 343 # by the current user
344 344 def allowed_parents
345 345 return @allowed_parents if @allowed_parents
346 346 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
347 347 @allowed_parents = @allowed_parents - self_and_descendants
348 348 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
349 349 @allowed_parents << nil
350 350 end
351 351 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
352 352 @allowed_parents << parent
353 353 end
354 354 @allowed_parents
355 355 end
356 356
357 357 # Sets the parent of the project with authorization check
358 358 def set_allowed_parent!(p)
359 359 unless p.nil? || p.is_a?(Project)
360 360 if p.to_s.blank?
361 361 p = nil
362 362 else
363 363 p = Project.find_by_id(p)
364 364 return false unless p
365 365 end
366 366 end
367 367 if p.nil?
368 368 if !new_record? && allowed_parents.empty?
369 369 return false
370 370 end
371 371 elsif !allowed_parents.include?(p)
372 372 return false
373 373 end
374 374 set_parent!(p)
375 375 end
376 376
377 377 # Sets the parent of the project
378 378 # Argument can be either a Project, a String, a Fixnum or nil
379 379 def set_parent!(p)
380 380 unless p.nil? || p.is_a?(Project)
381 381 if p.to_s.blank?
382 382 p = nil
383 383 else
384 384 p = Project.find_by_id(p)
385 385 return false unless p
386 386 end
387 387 end
388 388 if p == parent && !p.nil?
389 389 # Nothing to do
390 390 true
391 391 elsif p.nil? || (p.active? && move_possible?(p))
392 392 set_or_update_position_under(p)
393 393 Issue.update_versions_from_hierarchy_change(self)
394 394 true
395 395 else
396 396 # Can not move to the given target
397 397 false
398 398 end
399 399 end
400 400
401 401 # Recalculates all lft and rgt values based on project names
402 402 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
403 403 # Used in BuildProjectsTree migration
404 404 def self.rebuild_tree!
405 405 transaction do
406 406 update_all "lft = NULL, rgt = NULL"
407 407 rebuild!(false)
408 408 end
409 409 end
410 410
411 411 # Returns an array of the trackers used by the project and its active sub projects
412 412 def rolled_up_trackers
413 413 @rolled_up_trackers ||=
414 414 Tracker.
415 415 joins(:projects).
416 416 select("DISTINCT #{Tracker.table_name}.*").
417 417 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
418 418 sorted.
419 419 all
420 420 end
421 421
422 422 # Closes open and locked project versions that are completed
423 423 def close_completed_versions
424 424 Version.transaction do
425 425 versions.where(:status => %w(open locked)).all.each do |version|
426 426 if version.completed?
427 427 version.update_attribute(:status, 'closed')
428 428 end
429 429 end
430 430 end
431 431 end
432 432
433 433 # Returns a scope of the Versions on subprojects
434 434 def rolled_up_versions
435 435 @rolled_up_versions ||=
436 436 Version.scoped(:include => :project,
437 437 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
438 438 end
439 439
440 440 # Returns a scope of the Versions used by the project
441 441 def shared_versions
442 442 if new_record?
443 443 Version.scoped(:include => :project,
444 444 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
445 445 else
446 446 @shared_versions ||= begin
447 447 r = root? ? self : root
448 448 Version.scoped(:include => :project,
449 449 :conditions => "#{Project.table_name}.id = #{id}" +
450 450 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
451 451 " #{Version.table_name}.sharing = 'system'" +
452 452 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
453 453 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
454 454 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
455 455 "))")
456 456 end
457 457 end
458 458 end
459 459
460 460 # Returns a hash of project users grouped by role
461 461 def users_by_role
462 462 members.includes(:user, :roles).all.inject({}) do |h, m|
463 463 m.roles.each do |r|
464 464 h[r] ||= []
465 465 h[r] << m.user
466 466 end
467 467 h
468 468 end
469 469 end
470 470
471 471 # Deletes all project's members
472 472 def delete_all_members
473 473 me, mr = Member.table_name, MemberRole.table_name
474 474 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
475 475 Member.delete_all(['project_id = ?', id])
476 476 end
477 477
478 478 # Users/groups issues can be assigned to
479 479 def assignable_users
480 480 assignable = Setting.issue_group_assignment? ? member_principals : members
481 481 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
482 482 end
483 483
484 484 # Returns the mail adresses of users that should be always notified on project events
485 485 def recipients
486 486 notified_users.collect {|user| user.mail}
487 487 end
488 488
489 489 # Returns the users that should be notified on project events
490 490 def notified_users
491 491 # TODO: User part should be extracted to User#notify_about?
492 492 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
493 493 end
494 494
495 495 # Returns an array of all custom fields enabled for project issues
496 496 # (explictly associated custom fields and custom fields enabled for all projects)
497 497 def all_issue_custom_fields
498 498 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
499 499 end
500 500
501 501 # Returns an array of all custom fields enabled for project time entries
502 502 # (explictly associated custom fields and custom fields enabled for all projects)
503 503 def all_time_entry_custom_fields
504 504 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
505 505 end
506 506
507 507 def project
508 508 self
509 509 end
510 510
511 511 def <=>(project)
512 512 name.downcase <=> project.name.downcase
513 513 end
514 514
515 515 def to_s
516 516 name
517 517 end
518 518
519 519 # Returns a short description of the projects (first lines)
520 520 def short_description(length = 255)
521 521 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
522 522 end
523 523
524 524 def css_classes
525 525 s = 'project'
526 526 s << ' root' if root?
527 527 s << ' child' if child?
528 528 s << (leaf? ? ' leaf' : ' parent')
529 529 unless active?
530 530 if archived?
531 531 s << ' archived'
532 532 else
533 533 s << ' closed'
534 534 end
535 535 end
536 536 s
537 537 end
538 538
539 539 # The earliest start date of a project, based on it's issues and versions
540 540 def start_date
541 541 [
542 542 issues.minimum('start_date'),
543 543 shared_versions.collect(&:effective_date),
544 544 shared_versions.collect(&:start_date)
545 545 ].flatten.compact.min
546 546 end
547 547
548 548 # The latest due date of an issue or version
549 549 def due_date
550 550 [
551 551 issues.maximum('due_date'),
552 552 shared_versions.collect(&:effective_date),
553 553 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
554 554 ].flatten.compact.max
555 555 end
556 556
557 557 def overdue?
558 558 active? && !due_date.nil? && (due_date < Date.today)
559 559 end
560 560
561 561 # Returns the percent completed for this project, based on the
562 562 # progress on it's versions.
563 563 def completed_percent(options={:include_subprojects => false})
564 564 if options.delete(:include_subprojects)
565 565 total = self_and_descendants.collect(&:completed_percent).sum
566 566
567 567 total / self_and_descendants.count
568 568 else
569 569 if versions.count > 0
570 570 total = versions.collect(&:completed_pourcent).sum
571 571
572 572 total / versions.count
573 573 else
574 574 100
575 575 end
576 576 end
577 577 end
578 578
579 579 # Return true if this project allows to do the specified action.
580 580 # action can be:
581 581 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
582 582 # * a permission Symbol (eg. :edit_project)
583 583 def allows_to?(action)
584 584 if archived?
585 585 # No action allowed on archived projects
586 586 return false
587 587 end
588 588 unless active? || Redmine::AccessControl.read_action?(action)
589 589 # No write action allowed on closed projects
590 590 return false
591 591 end
592 592 # No action allowed on disabled modules
593 593 if action.is_a? Hash
594 594 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
595 595 else
596 596 allowed_permissions.include? action
597 597 end
598 598 end
599 599
600 600 def module_enabled?(module_name)
601 601 module_name = module_name.to_s
602 602 enabled_modules.detect {|m| m.name == module_name}
603 603 end
604 604
605 605 def enabled_module_names=(module_names)
606 606 if module_names && module_names.is_a?(Array)
607 607 module_names = module_names.collect(&:to_s).reject(&:blank?)
608 608 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
609 609 else
610 610 enabled_modules.clear
611 611 end
612 612 end
613 613
614 614 # Returns an array of the enabled modules names
615 615 def enabled_module_names
616 616 enabled_modules.collect(&:name)
617 617 end
618 618
619 619 # Enable a specific module
620 620 #
621 621 # Examples:
622 622 # project.enable_module!(:issue_tracking)
623 623 # project.enable_module!("issue_tracking")
624 624 def enable_module!(name)
625 625 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
626 626 end
627 627
628 628 # Disable a module if it exists
629 629 #
630 630 # Examples:
631 631 # project.disable_module!(:issue_tracking)
632 632 # project.disable_module!("issue_tracking")
633 633 # project.disable_module!(project.enabled_modules.first)
634 634 def disable_module!(target)
635 635 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
636 636 target.destroy unless target.blank?
637 637 end
638 638
639 639 safe_attributes 'name',
640 640 'description',
641 641 'homepage',
642 642 'is_public',
643 643 'identifier',
644 644 'custom_field_values',
645 645 'custom_fields',
646 646 'tracker_ids',
647 647 'issue_custom_field_ids'
648 648
649 649 safe_attributes 'enabled_module_names',
650 650 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
651 651
652 652 # Returns an array of projects that are in this project's hierarchy
653 653 #
654 654 # Example: parents, children, siblings
655 655 def hierarchy
656 656 parents = project.self_and_ancestors || []
657 657 descendants = project.descendants || []
658 658 project_hierarchy = parents | descendants # Set union
659 659 end
660 660
661 661 # Returns an auto-generated project identifier based on the last identifier used
662 662 def self.next_identifier
663 663 p = Project.order('created_on DESC').first
664 664 p.nil? ? nil : p.identifier.to_s.succ
665 665 end
666 666
667 667 # Copies and saves the Project instance based on the +project+.
668 668 # Duplicates the source project's:
669 669 # * Wiki
670 670 # * Versions
671 671 # * Categories
672 672 # * Issues
673 673 # * Members
674 674 # * Queries
675 675 #
676 676 # Accepts an +options+ argument to specify what to copy
677 677 #
678 678 # Examples:
679 679 # project.copy(1) # => copies everything
680 680 # project.copy(1, :only => 'members') # => copies members only
681 681 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
682 682 def copy(project, options={})
683 683 project = project.is_a?(Project) ? project : Project.find(project)
684 684
685 685 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
686 686 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
687 687
688 688 Project.transaction do
689 689 if save
690 690 reload
691 691 to_be_copied.each do |name|
692 692 send "copy_#{name}", project
693 693 end
694 694 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
695 695 save
696 696 end
697 697 end
698 698 end
699 699
700 700 # Returns a new unsaved Project instance with attributes copied from +project+
701 701 def self.copy_from(project)
702 702 project = project.is_a?(Project) ? project : Project.find(project)
703 703 # clear unique attributes
704 704 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
705 705 copy = Project.new(attributes)
706 706 copy.enabled_modules = project.enabled_modules
707 707 copy.trackers = project.trackers
708 708 copy.custom_values = project.custom_values.collect {|v| v.clone}
709 709 copy.issue_custom_fields = project.issue_custom_fields
710 710 copy
711 711 end
712 712
713 713 # Yields the given block for each project with its level in the tree
714 714 def self.project_tree(projects, &block)
715 715 ancestors = []
716 716 projects.sort_by(&:lft).each do |project|
717 717 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
718 718 ancestors.pop
719 719 end
720 720 yield project, ancestors.size
721 721 ancestors << project
722 722 end
723 723 end
724 724
725 725 private
726 726
727 727 # Copies wiki from +project+
728 728 def copy_wiki(project)
729 729 # Check that the source project has a wiki first
730 730 unless project.wiki.nil?
731 731 self.wiki ||= Wiki.new
732 732 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
733 733 wiki_pages_map = {}
734 734 project.wiki.pages.each do |page|
735 735 # Skip pages without content
736 736 next if page.content.nil?
737 737 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
738 738 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
739 739 new_wiki_page.content = new_wiki_content
740 740 wiki.pages << new_wiki_page
741 741 wiki_pages_map[page.id] = new_wiki_page
742 742 end
743 743 wiki.save
744 744 # Reproduce page hierarchy
745 745 project.wiki.pages.each do |page|
746 746 if page.parent_id && wiki_pages_map[page.id]
747 747 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
748 748 wiki_pages_map[page.id].save
749 749 end
750 750 end
751 751 end
752 752 end
753 753
754 754 # Copies versions from +project+
755 755 def copy_versions(project)
756 756 project.versions.each do |version|
757 757 new_version = Version.new
758 758 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
759 759 self.versions << new_version
760 760 end
761 761 end
762 762
763 763 # Copies issue categories from +project+
764 764 def copy_issue_categories(project)
765 765 project.issue_categories.each do |issue_category|
766 766 new_issue_category = IssueCategory.new
767 767 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
768 768 self.issue_categories << new_issue_category
769 769 end
770 770 end
771 771
772 772 # Copies issues from +project+
773 773 def copy_issues(project)
774 774 # Stores the source issue id as a key and the copied issues as the
775 775 # value. Used to map the two togeather for issue relations.
776 776 issues_map = {}
777 777
778 778 # Store status and reopen locked/closed versions
779 779 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
780 780 version_statuses.each do |version, status|
781 781 version.update_attribute :status, 'open'
782 782 end
783 783
784 784 # Get issues sorted by root_id, lft so that parent issues
785 785 # get copied before their children
786 786 project.issues.reorder('root_id, lft').all.each do |issue|
787 787 new_issue = Issue.new
788 788 new_issue.copy_from(issue, :subtasks => false, :link => false)
789 789 new_issue.project = self
790 790 # Reassign fixed_versions by name, since names are unique per project
791 791 if issue.fixed_version && issue.fixed_version.project == project
792 792 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
793 793 end
794 794 # Reassign the category by name, since names are unique per project
795 795 if issue.category
796 796 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
797 797 end
798 798 # Parent issue
799 799 if issue.parent_id
800 800 if copied_parent = issues_map[issue.parent_id]
801 801 new_issue.parent_issue_id = copied_parent.id
802 802 end
803 803 end
804 804
805 805 self.issues << new_issue
806 806 if new_issue.new_record?
807 807 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
808 808 else
809 809 issues_map[issue.id] = new_issue unless new_issue.new_record?
810 810 end
811 811 end
812 812
813 813 # Restore locked/closed version statuses
814 814 version_statuses.each do |version, status|
815 815 version.update_attribute :status, status
816 816 end
817 817
818 818 # Relations after in case issues related each other
819 819 project.issues.each do |issue|
820 820 new_issue = issues_map[issue.id]
821 821 unless new_issue
822 822 # Issue was not copied
823 823 next
824 824 end
825 825
826 826 # Relations
827 827 issue.relations_from.each do |source_relation|
828 828 new_issue_relation = IssueRelation.new
829 829 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
830 830 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
831 831 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
832 832 new_issue_relation.issue_to = source_relation.issue_to
833 833 end
834 834 new_issue.relations_from << new_issue_relation
835 835 end
836 836
837 837 issue.relations_to.each do |source_relation|
838 838 new_issue_relation = IssueRelation.new
839 839 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
840 840 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
841 841 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
842 842 new_issue_relation.issue_from = source_relation.issue_from
843 843 end
844 844 new_issue.relations_to << new_issue_relation
845 845 end
846 846 end
847 847 end
848 848
849 849 # Copies members from +project+
850 850 def copy_members(project)
851 851 # Copy users first, then groups to handle members with inherited and given roles
852 852 members_to_copy = []
853 853 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
854 854 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
855 855
856 856 members_to_copy.each do |member|
857 857 new_member = Member.new
858 858 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
859 859 # only copy non inherited roles
860 860 # inherited roles will be added when copying the group membership
861 861 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
862 862 next if role_ids.empty?
863 863 new_member.role_ids = role_ids
864 864 new_member.project = self
865 865 self.members << new_member
866 866 end
867 867 end
868 868
869 869 # Copies queries from +project+
870 870 def copy_queries(project)
871 871 project.queries.each do |query|
872 872 new_query = ::Query.new
873 873 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
874 874 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
875 875 new_query.project = self
876 876 new_query.user_id = query.user_id
877 877 self.queries << new_query
878 878 end
879 879 end
880 880
881 881 # Copies boards from +project+
882 882 def copy_boards(project)
883 883 project.boards.each do |board|
884 884 new_board = Board.new
885 885 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
886 886 new_board.project = self
887 887 self.boards << new_board
888 888 end
889 889 end
890 890
891 891 def allowed_permissions
892 892 @allowed_permissions ||= begin
893 893 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
894 894 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
895 895 end
896 896 end
897 897
898 898 def allowed_actions
899 899 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
900 900 end
901 901
902 902 # Returns all the active Systemwide and project specific activities
903 903 def active_activities
904 904 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
905 905
906 906 if overridden_activity_ids.empty?
907 907 return TimeEntryActivity.shared.active
908 908 else
909 909 return system_activities_and_project_overrides
910 910 end
911 911 end
912 912
913 913 # Returns all the Systemwide and project specific activities
914 914 # (inactive and active)
915 915 def all_activities
916 916 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
917 917
918 918 if overridden_activity_ids.empty?
919 919 return TimeEntryActivity.shared
920 920 else
921 921 return system_activities_and_project_overrides(true)
922 922 end
923 923 end
924 924
925 925 # Returns the systemwide active activities merged with the project specific overrides
926 926 def system_activities_and_project_overrides(include_inactive=false)
927 927 if include_inactive
928 928 return TimeEntryActivity.shared.
929 929 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
930 930 self.time_entry_activities
931 931 else
932 932 return TimeEntryActivity.shared.active.
933 933 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
934 934 self.time_entry_activities.active
935 935 end
936 936 end
937 937
938 938 # Archives subprojects recursively
939 939 def archive!
940 940 children.each do |subproject|
941 941 subproject.send :archive!
942 942 end
943 943 update_attribute :status, STATUS_ARCHIVED
944 944 end
945 945
946 946 def update_position_under_parent
947 947 set_or_update_position_under(parent)
948 948 end
949 949
950 950 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
951 951 def set_or_update_position_under(target_parent)
952 952 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
953 953 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
954 954
955 955 if to_be_inserted_before
956 956 move_to_left_of(to_be_inserted_before)
957 957 elsif target_parent.nil?
958 958 if sibs.empty?
959 959 # move_to_root adds the project in first (ie. left) position
960 960 move_to_root
961 961 else
962 962 move_to_right_of(sibs.last) unless self == sibs.last
963 963 end
964 964 else
965 965 # move_to_child_of adds the project in last (ie.right) position
966 966 move_to_child_of(target_parent)
967 967 end
968 968 end
969 969 end
@@ -1,438 +1,438
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class ScmFetchError < Exception; end
19 19
20 20 class Repository < ActiveRecord::Base
21 21 include Redmine::Ciphering
22 22 include Redmine::SafeAttributes
23 23
24 24 # Maximum length for repository identifiers
25 25 IDENTIFIER_MAX_LENGTH = 255
26 26
27 27 belongs_to :project
28 28 has_many :changesets, :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC"
29 29 has_many :filechanges, :class_name => 'Change', :through => :changesets
30 30
31 31 serialize :extra_info
32 32
33 33 before_save :check_default
34 34
35 35 # Raw SQL to delete changesets and changes in the database
36 36 # has_many :changesets, :dependent => :destroy is too slow for big repositories
37 37 before_destroy :clear_changesets
38 38
39 39 validates_length_of :password, :maximum => 255, :allow_nil => true
40 40 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH, :allow_blank => true
41 41 validates_presence_of :identifier, :unless => Proc.new { |r| r.is_default? || r.set_as_default? }
42 42 validates_uniqueness_of :identifier, :scope => :project_id, :allow_blank => true
43 43 validates_exclusion_of :identifier, :in => %w(show entry raw changes annotate diff show stats graph)
44 44 # donwcase letters, digits, dashes, underscores but not digits only
45 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :allow_blank => true
45 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :allow_blank => true
46 46 # Checks if the SCM is enabled when creating a repository
47 47 validate :repo_create_validation, :on => :create
48 48
49 49 safe_attributes 'identifier',
50 50 'login',
51 51 'password',
52 52 'path_encoding',
53 53 'log_encoding',
54 54 'is_default'
55 55
56 56 safe_attributes 'url',
57 57 :if => lambda {|repository, user| repository.new_record?}
58 58
59 59 def repo_create_validation
60 60 unless Setting.enabled_scm.include?(self.class.name.demodulize)
61 61 errors.add(:type, :invalid)
62 62 end
63 63 end
64 64
65 65 def self.human_attribute_name(attribute_key_name, *args)
66 66 attr_name = attribute_key_name.to_s
67 67 if attr_name == "log_encoding"
68 68 attr_name = "commit_logs_encoding"
69 69 end
70 70 super(attr_name, *args)
71 71 end
72 72
73 73 # Removes leading and trailing whitespace
74 74 def url=(arg)
75 75 write_attribute(:url, arg ? arg.to_s.strip : nil)
76 76 end
77 77
78 78 # Removes leading and trailing whitespace
79 79 def root_url=(arg)
80 80 write_attribute(:root_url, arg ? arg.to_s.strip : nil)
81 81 end
82 82
83 83 def password
84 84 read_ciphered_attribute(:password)
85 85 end
86 86
87 87 def password=(arg)
88 88 write_ciphered_attribute(:password, arg)
89 89 end
90 90
91 91 def scm_adapter
92 92 self.class.scm_adapter_class
93 93 end
94 94
95 95 def scm
96 96 unless @scm
97 97 @scm = self.scm_adapter.new(url, root_url,
98 98 login, password, path_encoding)
99 99 if root_url.blank? && @scm.root_url.present?
100 100 update_attribute(:root_url, @scm.root_url)
101 101 end
102 102 end
103 103 @scm
104 104 end
105 105
106 106 def scm_name
107 107 self.class.scm_name
108 108 end
109 109
110 110 def name
111 111 if identifier.present?
112 112 identifier
113 113 elsif is_default?
114 114 l(:field_repository_is_default)
115 115 else
116 116 scm_name
117 117 end
118 118 end
119 119
120 120 def identifier=(identifier)
121 121 super unless identifier_frozen?
122 122 end
123 123
124 124 def identifier_frozen?
125 125 errors[:identifier].blank? && !(new_record? || identifier.blank?)
126 126 end
127 127
128 128 def identifier_param
129 129 if is_default?
130 130 nil
131 131 elsif identifier.present?
132 132 identifier
133 133 else
134 134 id.to_s
135 135 end
136 136 end
137 137
138 138 def <=>(repository)
139 139 if is_default?
140 140 -1
141 141 elsif repository.is_default?
142 142 1
143 143 else
144 144 identifier.to_s <=> repository.identifier.to_s
145 145 end
146 146 end
147 147
148 148 def self.find_by_identifier_param(param)
149 149 if param.to_s =~ /^\d+$/
150 150 find_by_id(param)
151 151 else
152 152 find_by_identifier(param)
153 153 end
154 154 end
155 155
156 156 def merge_extra_info(arg)
157 157 h = extra_info || {}
158 158 return h if arg.nil?
159 159 h.merge!(arg)
160 160 write_attribute(:extra_info, h)
161 161 end
162 162
163 163 def report_last_commit
164 164 true
165 165 end
166 166
167 167 def supports_cat?
168 168 scm.supports_cat?
169 169 end
170 170
171 171 def supports_annotate?
172 172 scm.supports_annotate?
173 173 end
174 174
175 175 def supports_all_revisions?
176 176 true
177 177 end
178 178
179 179 def supports_directory_revisions?
180 180 false
181 181 end
182 182
183 183 def supports_revision_graph?
184 184 false
185 185 end
186 186
187 187 def entry(path=nil, identifier=nil)
188 188 scm.entry(path, identifier)
189 189 end
190 190
191 191 def entries(path=nil, identifier=nil)
192 192 entries = scm.entries(path, identifier)
193 193 load_entries_changesets(entries)
194 194 entries
195 195 end
196 196
197 197 def branches
198 198 scm.branches
199 199 end
200 200
201 201 def tags
202 202 scm.tags
203 203 end
204 204
205 205 def default_branch
206 206 nil
207 207 end
208 208
209 209 def properties(path, identifier=nil)
210 210 scm.properties(path, identifier)
211 211 end
212 212
213 213 def cat(path, identifier=nil)
214 214 scm.cat(path, identifier)
215 215 end
216 216
217 217 def diff(path, rev, rev_to)
218 218 scm.diff(path, rev, rev_to)
219 219 end
220 220
221 221 def diff_format_revisions(cs, cs_to, sep=':')
222 222 text = ""
223 223 text << cs_to.format_identifier + sep if cs_to
224 224 text << cs.format_identifier if cs
225 225 text
226 226 end
227 227
228 228 # Returns a path relative to the url of the repository
229 229 def relative_path(path)
230 230 path
231 231 end
232 232
233 233 # Finds and returns a revision with a number or the beginning of a hash
234 234 def find_changeset_by_name(name)
235 235 return nil if name.blank?
236 236 s = name.to_s
237 237 if s.match(/^\d*$/)
238 238 changesets.where("revision = ?", s).first
239 239 else
240 240 changesets.where("revision LIKE ?", s + '%').first
241 241 end
242 242 end
243 243
244 244 def latest_changeset
245 245 @latest_changeset ||= changesets.first
246 246 end
247 247
248 248 # Returns the latest changesets for +path+
249 249 # Default behaviour is to search in cached changesets
250 250 def latest_changesets(path, rev, limit=10)
251 251 if path.blank?
252 252 changesets.find(
253 253 :all,
254 254 :include => :user,
255 255 :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC",
256 256 :limit => limit)
257 257 else
258 258 filechanges.find(
259 259 :all,
260 260 :include => {:changeset => :user},
261 261 :conditions => ["path = ?", path.with_leading_slash],
262 262 :order => "#{Changeset.table_name}.committed_on DESC, #{Changeset.table_name}.id DESC",
263 263 :limit => limit
264 264 ).collect(&:changeset)
265 265 end
266 266 end
267 267
268 268 def scan_changesets_for_issue_ids
269 269 self.changesets.each(&:scan_comment_for_issue_ids)
270 270 end
271 271
272 272 # Returns an array of committers usernames and associated user_id
273 273 def committers
274 274 @committers ||= Changeset.connection.select_rows(
275 275 "SELECT DISTINCT committer, user_id FROM #{Changeset.table_name} WHERE repository_id = #{id}")
276 276 end
277 277
278 278 # Maps committers username to a user ids
279 279 def committer_ids=(h)
280 280 if h.is_a?(Hash)
281 281 committers.each do |committer, user_id|
282 282 new_user_id = h[committer]
283 283 if new_user_id && (new_user_id.to_i != user_id.to_i)
284 284 new_user_id = (new_user_id.to_i > 0 ? new_user_id.to_i : nil)
285 285 Changeset.update_all(
286 286 "user_id = #{ new_user_id.nil? ? 'NULL' : new_user_id }",
287 287 ["repository_id = ? AND committer = ?", id, committer])
288 288 end
289 289 end
290 290 @committers = nil
291 291 @found_committer_users = nil
292 292 true
293 293 else
294 294 false
295 295 end
296 296 end
297 297
298 298 # Returns the Redmine User corresponding to the given +committer+
299 299 # It will return nil if the committer is not yet mapped and if no User
300 300 # with the same username or email was found
301 301 def find_committer_user(committer)
302 302 unless committer.blank?
303 303 @found_committer_users ||= {}
304 304 return @found_committer_users[committer] if @found_committer_users.has_key?(committer)
305 305
306 306 user = nil
307 307 c = changesets.where(:committer => committer).includes(:user).first
308 308 if c && c.user
309 309 user = c.user
310 310 elsif committer.strip =~ /^([^<]+)(<(.*)>)?$/
311 311 username, email = $1.strip, $3
312 312 u = User.find_by_login(username)
313 313 u ||= User.find_by_mail(email) unless email.blank?
314 314 user = u
315 315 end
316 316 @found_committer_users[committer] = user
317 317 user
318 318 end
319 319 end
320 320
321 321 def repo_log_encoding
322 322 encoding = log_encoding.to_s.strip
323 323 encoding.blank? ? 'UTF-8' : encoding
324 324 end
325 325
326 326 # Fetches new changesets for all repositories of active projects
327 327 # Can be called periodically by an external script
328 328 # eg. ruby script/runner "Repository.fetch_changesets"
329 329 def self.fetch_changesets
330 330 Project.active.has_module(:repository).all.each do |project|
331 331 project.repositories.each do |repository|
332 332 begin
333 333 repository.fetch_changesets
334 334 rescue Redmine::Scm::Adapters::CommandFailed => e
335 335 logger.error "scm: error during fetching changesets: #{e.message}"
336 336 end
337 337 end
338 338 end
339 339 end
340 340
341 341 # scan changeset comments to find related and fixed issues for all repositories
342 342 def self.scan_changesets_for_issue_ids
343 343 all.each(&:scan_changesets_for_issue_ids)
344 344 end
345 345
346 346 def self.scm_name
347 347 'Abstract'
348 348 end
349 349
350 350 def self.available_scm
351 351 subclasses.collect {|klass| [klass.scm_name, klass.name]}
352 352 end
353 353
354 354 def self.factory(klass_name, *args)
355 355 klass = "Repository::#{klass_name}".constantize
356 356 klass.new(*args)
357 357 rescue
358 358 nil
359 359 end
360 360
361 361 def self.scm_adapter_class
362 362 nil
363 363 end
364 364
365 365 def self.scm_command
366 366 ret = ""
367 367 begin
368 368 ret = self.scm_adapter_class.client_command if self.scm_adapter_class
369 369 rescue Exception => e
370 370 logger.error "scm: error during get command: #{e.message}"
371 371 end
372 372 ret
373 373 end
374 374
375 375 def self.scm_version_string
376 376 ret = ""
377 377 begin
378 378 ret = self.scm_adapter_class.client_version_string if self.scm_adapter_class
379 379 rescue Exception => e
380 380 logger.error "scm: error during get version string: #{e.message}"
381 381 end
382 382 ret
383 383 end
384 384
385 385 def self.scm_available
386 386 ret = false
387 387 begin
388 388 ret = self.scm_adapter_class.client_available if self.scm_adapter_class
389 389 rescue Exception => e
390 390 logger.error "scm: error during get scm available: #{e.message}"
391 391 end
392 392 ret
393 393 end
394 394
395 395 def set_as_default?
396 396 new_record? && project && !Repository.first(:conditions => {:project_id => project.id})
397 397 end
398 398
399 399 protected
400 400
401 401 def check_default
402 402 if !is_default? && set_as_default?
403 403 self.is_default = true
404 404 end
405 405 if is_default? && is_default_changed?
406 406 Repository.update_all(["is_default = ?", false], ["project_id = ?", project_id])
407 407 end
408 408 end
409 409
410 410 def load_entries_changesets(entries)
411 411 if entries
412 412 entries.each do |entry|
413 413 if entry.lastrev && entry.lastrev.identifier
414 414 entry.changeset = find_changeset_by_name(entry.lastrev.identifier)
415 415 end
416 416 end
417 417 end
418 418 end
419 419
420 420 private
421 421
422 422 # Deletes repository data
423 423 def clear_changesets
424 424 cs = Changeset.table_name
425 425 ch = Change.table_name
426 426 ci = "#{table_name_prefix}changesets_issues#{table_name_suffix}"
427 427 cp = "#{table_name_prefix}changeset_parents#{table_name_suffix}"
428 428
429 429 connection.delete("DELETE FROM #{ch} WHERE #{ch}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
430 430 connection.delete("DELETE FROM #{ci} WHERE #{ci}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
431 431 connection.delete("DELETE FROM #{cp} WHERE #{cp}.changeset_id IN (SELECT #{cs}.id FROM #{cs} WHERE #{cs}.repository_id = #{id})")
432 432 connection.delete("DELETE FROM #{cs} WHERE #{cs}.repository_id = #{id}")
433 433 clear_extra_info_of_changesets
434 434 end
435 435
436 436 def clear_extra_info_of_changesets
437 437 end
438 438 end
@@ -1,116 +1,116
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'redmine/scm/adapters/subversion_adapter'
19 19
20 20 class Repository::Subversion < Repository
21 21 attr_protected :root_url
22 22 validates_presence_of :url
23 validates_format_of :url, :with => /^(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+/i
23 validates_format_of :url, :with => /\A(http|https|svn(\+[^\s:\/\\]+)?|file):\/\/.+/i
24 24
25 25 def self.scm_adapter_class
26 26 Redmine::Scm::Adapters::SubversionAdapter
27 27 end
28 28
29 29 def self.scm_name
30 30 'Subversion'
31 31 end
32 32
33 33 def supports_directory_revisions?
34 34 true
35 35 end
36 36
37 37 def repo_log_encoding
38 38 'UTF-8'
39 39 end
40 40
41 41 def latest_changesets(path, rev, limit=10)
42 42 revisions = scm.revisions(path, rev, nil, :limit => limit)
43 43 if revisions
44 44 identifiers = revisions.collect(&:identifier).compact
45 45 changesets.where(:revision => identifiers).reorder("committed_on DESC").includes(:repository, :user).all
46 46 else
47 47 []
48 48 end
49 49 end
50 50
51 51 # Returns a path relative to the url of the repository
52 52 def relative_path(path)
53 53 path.gsub(Regexp.new("^\/?#{Regexp.escape(relative_url)}"), '')
54 54 end
55 55
56 56 def fetch_changesets
57 57 scm_info = scm.info
58 58 if scm_info
59 59 # latest revision found in database
60 60 db_revision = latest_changeset ? latest_changeset.revision.to_i : 0
61 61 # latest revision in the repository
62 62 scm_revision = scm_info.lastrev.identifier.to_i
63 63 if db_revision < scm_revision
64 64 logger.debug "Fetching changesets for repository #{url}" if logger && logger.debug?
65 65 identifier_from = db_revision + 1
66 66 while (identifier_from <= scm_revision)
67 67 # loads changesets by batches of 200
68 68 identifier_to = [identifier_from + 199, scm_revision].min
69 69 revisions = scm.revisions('', identifier_to, identifier_from, :with_paths => true)
70 70 revisions.reverse_each do |revision|
71 71 transaction do
72 72 changeset = Changeset.create(:repository => self,
73 73 :revision => revision.identifier,
74 74 :committer => revision.author,
75 75 :committed_on => revision.time,
76 76 :comments => revision.message)
77 77
78 78 revision.paths.each do |change|
79 79 changeset.create_change(change)
80 80 end unless changeset.new_record?
81 81 end
82 82 end unless revisions.nil?
83 83 identifier_from = identifier_to + 1
84 84 end
85 85 end
86 86 end
87 87 end
88 88
89 89 protected
90 90
91 91 def load_entries_changesets(entries)
92 92 return unless entries
93 93
94 94 entries_with_identifier = entries.select {|entry| entry.lastrev && entry.lastrev.identifier.present?}
95 95 identifiers = entries_with_identifier.map {|entry| entry.lastrev.identifier}.compact.uniq
96 96
97 97 if identifiers.any?
98 98 changesets_by_identifier = changesets.where(:revision => identifiers).includes(:user, :repository).all.group_by(&:revision)
99 99 entries_with_identifier.each do |entry|
100 100 if m = changesets_by_identifier[entry.lastrev.identifier]
101 101 entry.changeset = m.first
102 102 end
103 103 end
104 104 end
105 105 end
106 106
107 107 private
108 108
109 109 # Returns the relative url of the repository
110 110 # Eg: root_url = file:///var/svn/foo
111 111 # url = file:///var/svn/foo/bar
112 112 # => returns /bar
113 113 def relative_url
114 114 @relative_url ||= url.gsub(Regexp.new("^#{Regexp.escape(root_url || scm.root_url)}", Regexp::IGNORECASE), '')
115 115 end
116 116 end
@@ -1,706 +1,706
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "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 :firstname => {
36 36 :string => '#{firstname}',
37 37 :order => %w(firstname id),
38 38 :setting_order => 3
39 39 },
40 40 :lastname_firstname => {
41 41 :string => '#{lastname} #{firstname}',
42 42 :order => %w(lastname firstname id),
43 43 :setting_order => 4
44 44 },
45 45 :lastname_coma_firstname => {
46 46 :string => '#{lastname}, #{firstname}',
47 47 :order => %w(lastname firstname id),
48 48 :setting_order => 5
49 49 },
50 50 :lastname => {
51 51 :string => '#{lastname}',
52 52 :order => %w(lastname id),
53 53 :setting_order => 6
54 54 },
55 55 :username => {
56 56 :string => '#{login}',
57 57 :order => %w(login id),
58 58 :setting_order => 7
59 59 },
60 60 }
61 61
62 62 MAIL_NOTIFICATION_OPTIONS = [
63 63 ['all', :label_user_mail_option_all],
64 64 ['selected', :label_user_mail_option_selected],
65 65 ['only_my_events', :label_user_mail_option_only_my_events],
66 66 ['only_assigned', :label_user_mail_option_only_assigned],
67 67 ['only_owner', :label_user_mail_option_only_owner],
68 68 ['none', :label_user_mail_option_none]
69 69 ]
70 70
71 71 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
72 72 :after_remove => Proc.new {|user, group| group.user_removed(user)}
73 73 has_many :changesets, :dependent => :nullify
74 74 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
75 75 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
76 76 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
77 77 belongs_to :auth_source
78 78
79 79 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
80 80 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
81 81
82 82 acts_as_customizable
83 83
84 84 attr_accessor :password, :password_confirmation
85 85 attr_accessor :last_before_login_on
86 86 # Prevents unauthorized assignments
87 87 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
88 88
89 89 LOGIN_LENGTH_LIMIT = 60
90 90 MAIL_LENGTH_LIMIT = 60
91 91
92 92 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
93 93 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
94 94 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
95 95 # Login must contain lettres, numbers, underscores only
96 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
96 validates_format_of :login, :with => /\A[a-z0-9_\-@\.]*\z/i
97 97 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
98 98 validates_length_of :firstname, :lastname, :maximum => 30
99 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
99 validates_format_of :mail, :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/i, :allow_blank => true
100 100 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
101 101 validates_confirmation_of :password, :allow_nil => true
102 102 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
103 103 validate :validate_password_length
104 104
105 105 before_create :set_mail_notification
106 106 before_save :update_hashed_password
107 107 before_destroy :remove_references_before_destroy
108 108
109 109 scope :in_group, lambda {|group|
110 110 group_id = group.is_a?(Group) ? group.id : group.to_i
111 111 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)
112 112 }
113 113 scope :not_in_group, lambda {|group|
114 114 group_id = group.is_a?(Group) ? group.id : group.to_i
115 115 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)
116 116 }
117 117
118 118 def set_mail_notification
119 119 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
120 120 true
121 121 end
122 122
123 123 def update_hashed_password
124 124 # update hashed_password if password was set
125 125 if self.password && self.auth_source_id.blank?
126 126 salt_password(password)
127 127 end
128 128 end
129 129
130 130 def reload(*args)
131 131 @name = nil
132 132 @projects_by_role = nil
133 133 super
134 134 end
135 135
136 136 def mail=(arg)
137 137 write_attribute(:mail, arg.to_s.strip)
138 138 end
139 139
140 140 def identity_url=(url)
141 141 if url.blank?
142 142 write_attribute(:identity_url, '')
143 143 else
144 144 begin
145 145 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
146 146 rescue OpenIdAuthentication::InvalidOpenId
147 147 # Invlaid url, don't save
148 148 end
149 149 end
150 150 self.read_attribute(:identity_url)
151 151 end
152 152
153 153 # Returns the user that matches provided login and password, or nil
154 154 def self.try_to_login(login, password)
155 155 login = login.to_s
156 156 password = password.to_s
157 157
158 158 # Make sure no one can sign in with an empty password
159 159 return nil if password.empty?
160 160 user = find_by_login(login)
161 161 if user
162 162 # user is already in local database
163 163 return nil if !user.active?
164 164 if user.auth_source
165 165 # user has an external authentication method
166 166 return nil unless user.auth_source.authenticate(login, password)
167 167 else
168 168 # authentication with local password
169 169 return nil unless user.check_password?(password)
170 170 end
171 171 else
172 172 # user is not yet registered, try to authenticate with available sources
173 173 attrs = AuthSource.authenticate(login, password)
174 174 if attrs
175 175 user = new(attrs)
176 176 user.login = login
177 177 user.language = Setting.default_language
178 178 if user.save
179 179 user.reload
180 180 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
181 181 end
182 182 end
183 183 end
184 184 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
185 185 user
186 186 rescue => text
187 187 raise text
188 188 end
189 189
190 190 # Returns the user who matches the given autologin +key+ or nil
191 191 def self.try_to_autologin(key)
192 192 tokens = Token.find_all_by_action_and_value('autologin', key.to_s)
193 193 # Make sure there's only 1 token that matches the key
194 194 if tokens.size == 1
195 195 token = tokens.first
196 196 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
197 197 token.user.update_attribute(:last_login_on, Time.now)
198 198 token.user
199 199 end
200 200 end
201 201 end
202 202
203 203 def self.name_formatter(formatter = nil)
204 204 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
205 205 end
206 206
207 207 # Returns an array of fields names than can be used to make an order statement for users
208 208 # according to how user names are displayed
209 209 # Examples:
210 210 #
211 211 # User.fields_for_order_statement => ['users.login', 'users.id']
212 212 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
213 213 def self.fields_for_order_statement(table=nil)
214 214 table ||= table_name
215 215 name_formatter[:order].map {|field| "#{table}.#{field}"}
216 216 end
217 217
218 218 # Return user's full name for display
219 219 def name(formatter = nil)
220 220 f = self.class.name_formatter(formatter)
221 221 if formatter
222 222 eval('"' + f[:string] + '"')
223 223 else
224 224 @name ||= eval('"' + f[:string] + '"')
225 225 end
226 226 end
227 227
228 228 def active?
229 229 self.status == STATUS_ACTIVE
230 230 end
231 231
232 232 def registered?
233 233 self.status == STATUS_REGISTERED
234 234 end
235 235
236 236 def locked?
237 237 self.status == STATUS_LOCKED
238 238 end
239 239
240 240 def activate
241 241 self.status = STATUS_ACTIVE
242 242 end
243 243
244 244 def register
245 245 self.status = STATUS_REGISTERED
246 246 end
247 247
248 248 def lock
249 249 self.status = STATUS_LOCKED
250 250 end
251 251
252 252 def activate!
253 253 update_attribute(:status, STATUS_ACTIVE)
254 254 end
255 255
256 256 def register!
257 257 update_attribute(:status, STATUS_REGISTERED)
258 258 end
259 259
260 260 def lock!
261 261 update_attribute(:status, STATUS_LOCKED)
262 262 end
263 263
264 264 # Returns true if +clear_password+ is the correct user's password, otherwise false
265 265 def check_password?(clear_password)
266 266 if auth_source_id.present?
267 267 auth_source.authenticate(self.login, clear_password)
268 268 else
269 269 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
270 270 end
271 271 end
272 272
273 273 # Generates a random salt and computes hashed_password for +clear_password+
274 274 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
275 275 def salt_password(clear_password)
276 276 self.salt = User.generate_salt
277 277 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
278 278 end
279 279
280 280 # Does the backend storage allow this user to change their password?
281 281 def change_password_allowed?
282 282 return true if auth_source.nil?
283 283 return auth_source.allow_password_changes?
284 284 end
285 285
286 286 # Generate and set a random password. Useful for automated user creation
287 287 # Based on Token#generate_token_value
288 288 #
289 289 def random_password
290 290 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
291 291 password = ''
292 292 40.times { |i| password << chars[rand(chars.size-1)] }
293 293 self.password = password
294 294 self.password_confirmation = password
295 295 self
296 296 end
297 297
298 298 def pref
299 299 self.preference ||= UserPreference.new(:user => self)
300 300 end
301 301
302 302 def time_zone
303 303 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
304 304 end
305 305
306 306 def wants_comments_in_reverse_order?
307 307 self.pref[:comments_sorting] == 'desc'
308 308 end
309 309
310 310 # Return user's RSS key (a 40 chars long string), used to access feeds
311 311 def rss_key
312 312 if rss_token.nil?
313 313 create_rss_token(:action => 'feeds')
314 314 end
315 315 rss_token.value
316 316 end
317 317
318 318 # Return user's API key (a 40 chars long string), used to access the API
319 319 def api_key
320 320 if api_token.nil?
321 321 create_api_token(:action => 'api')
322 322 end
323 323 api_token.value
324 324 end
325 325
326 326 # Return an array of project ids for which the user has explicitly turned mail notifications on
327 327 def notified_projects_ids
328 328 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
329 329 end
330 330
331 331 def notified_project_ids=(ids)
332 332 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
333 333 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
334 334 @notified_projects_ids = nil
335 335 notified_projects_ids
336 336 end
337 337
338 338 def valid_notification_options
339 339 self.class.valid_notification_options(self)
340 340 end
341 341
342 342 # Only users that belong to more than 1 project can select projects for which they are notified
343 343 def self.valid_notification_options(user=nil)
344 344 # Note that @user.membership.size would fail since AR ignores
345 345 # :include association option when doing a count
346 346 if user.nil? || user.memberships.length < 1
347 347 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
348 348 else
349 349 MAIL_NOTIFICATION_OPTIONS
350 350 end
351 351 end
352 352
353 353 # Find a user account by matching the exact login and then a case-insensitive
354 354 # version. Exact matches will be given priority.
355 355 def self.find_by_login(login)
356 356 # First look for an exact match
357 357 user = where(:login => login).all.detect {|u| u.login == login}
358 358 unless user
359 359 # Fail over to case-insensitive if none was found
360 360 user = where("LOWER(login) = ?", login.to_s.downcase).first
361 361 end
362 362 user
363 363 end
364 364
365 365 def self.find_by_rss_key(key)
366 366 token = Token.find_by_action_and_value('feeds', key.to_s)
367 367 token && token.user.active? ? token.user : nil
368 368 end
369 369
370 370 def self.find_by_api_key(key)
371 371 token = Token.find_by_action_and_value('api', key.to_s)
372 372 token && token.user.active? ? token.user : nil
373 373 end
374 374
375 375 # Makes find_by_mail case-insensitive
376 376 def self.find_by_mail(mail)
377 377 where("LOWER(mail) = ?", mail.to_s.downcase).first
378 378 end
379 379
380 380 # Returns true if the default admin account can no longer be used
381 381 def self.default_admin_account_changed?
382 382 !User.active.find_by_login("admin").try(:check_password?, "admin")
383 383 end
384 384
385 385 def to_s
386 386 name
387 387 end
388 388
389 389 CSS_CLASS_BY_STATUS = {
390 390 STATUS_ANONYMOUS => 'anon',
391 391 STATUS_ACTIVE => 'active',
392 392 STATUS_REGISTERED => 'registered',
393 393 STATUS_LOCKED => 'locked'
394 394 }
395 395
396 396 def css_classes
397 397 "user #{CSS_CLASS_BY_STATUS[status]}"
398 398 end
399 399
400 400 # Returns the current day according to user's time zone
401 401 def today
402 402 if time_zone.nil?
403 403 Date.today
404 404 else
405 405 Time.now.in_time_zone(time_zone).to_date
406 406 end
407 407 end
408 408
409 409 # Returns the day of +time+ according to user's time zone
410 410 def time_to_date(time)
411 411 if time_zone.nil?
412 412 time.to_date
413 413 else
414 414 time.in_time_zone(time_zone).to_date
415 415 end
416 416 end
417 417
418 418 def logged?
419 419 true
420 420 end
421 421
422 422 def anonymous?
423 423 !logged?
424 424 end
425 425
426 426 # Return user's roles for project
427 427 def roles_for_project(project)
428 428 roles = []
429 429 # No role on archived projects
430 430 return roles if project.nil? || project.archived?
431 431 if logged?
432 432 # Find project membership
433 433 membership = memberships.detect {|m| m.project_id == project.id}
434 434 if membership
435 435 roles = membership.roles
436 436 else
437 437 @role_non_member ||= Role.non_member
438 438 roles << @role_non_member
439 439 end
440 440 else
441 441 @role_anonymous ||= Role.anonymous
442 442 roles << @role_anonymous
443 443 end
444 444 roles
445 445 end
446 446
447 447 # Return true if the user is a member of project
448 448 def member_of?(project)
449 449 !roles_for_project(project).detect {|role| role.member?}.nil?
450 450 end
451 451
452 452 # Returns a hash of user's projects grouped by roles
453 453 def projects_by_role
454 454 return @projects_by_role if @projects_by_role
455 455
456 456 @projects_by_role = Hash.new([])
457 457 memberships.each do |membership|
458 458 if membership.project
459 459 membership.roles.each do |role|
460 460 @projects_by_role[role] = [] unless @projects_by_role.key?(role)
461 461 @projects_by_role[role] << membership.project
462 462 end
463 463 end
464 464 end
465 465 @projects_by_role.each do |role, projects|
466 466 projects.uniq!
467 467 end
468 468
469 469 @projects_by_role
470 470 end
471 471
472 472 # Returns true if user is arg or belongs to arg
473 473 def is_or_belongs_to?(arg)
474 474 if arg.is_a?(User)
475 475 self == arg
476 476 elsif arg.is_a?(Group)
477 477 arg.users.include?(self)
478 478 else
479 479 false
480 480 end
481 481 end
482 482
483 483 # Return true if the user is allowed to do the specified action on a specific context
484 484 # Action can be:
485 485 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
486 486 # * a permission Symbol (eg. :edit_project)
487 487 # Context can be:
488 488 # * a project : returns true if user is allowed to do the specified action on this project
489 489 # * an array of projects : returns true if user is allowed on every project
490 490 # * nil with options[:global] set : check if user has at least one role allowed for this action,
491 491 # or falls back to Non Member / Anonymous permissions depending if the user is logged
492 492 def allowed_to?(action, context, options={}, &block)
493 493 if context && context.is_a?(Project)
494 494 return false unless context.allows_to?(action)
495 495 # Admin users are authorized for anything else
496 496 return true if admin?
497 497
498 498 roles = roles_for_project(context)
499 499 return false unless roles
500 500 roles.any? {|role|
501 501 (context.is_public? || role.member?) &&
502 502 role.allowed_to?(action) &&
503 503 (block_given? ? yield(role, self) : true)
504 504 }
505 505 elsif context && context.is_a?(Array)
506 506 if context.empty?
507 507 false
508 508 else
509 509 # Authorize if user is authorized on every element of the array
510 510 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
511 511 end
512 512 elsif options[:global]
513 513 # Admin users are always authorized
514 514 return true if admin?
515 515
516 516 # authorize if user has at least one role that has this permission
517 517 roles = memberships.collect {|m| m.roles}.flatten.uniq
518 518 roles << (self.logged? ? Role.non_member : Role.anonymous)
519 519 roles.any? {|role|
520 520 role.allowed_to?(action) &&
521 521 (block_given? ? yield(role, self) : true)
522 522 }
523 523 else
524 524 false
525 525 end
526 526 end
527 527
528 528 # Is the user allowed to do the specified action on any project?
529 529 # See allowed_to? for the actions and valid options.
530 530 def allowed_to_globally?(action, options, &block)
531 531 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
532 532 end
533 533
534 534 # Returns true if the user is allowed to delete his own account
535 535 def own_account_deletable?
536 536 Setting.unsubscribe? &&
537 537 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
538 538 end
539 539
540 540 safe_attributes 'login',
541 541 'firstname',
542 542 'lastname',
543 543 'mail',
544 544 'mail_notification',
545 545 'language',
546 546 'custom_field_values',
547 547 'custom_fields',
548 548 'identity_url'
549 549
550 550 safe_attributes 'status',
551 551 'auth_source_id',
552 552 :if => lambda {|user, current_user| current_user.admin?}
553 553
554 554 safe_attributes 'group_ids',
555 555 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
556 556
557 557 # Utility method to help check if a user should be notified about an
558 558 # event.
559 559 #
560 560 # TODO: only supports Issue events currently
561 561 def notify_about?(object)
562 562 case mail_notification
563 563 when 'all'
564 564 true
565 565 when 'selected'
566 566 # user receives notifications for created/assigned issues on unselected projects
567 567 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
568 568 true
569 569 else
570 570 false
571 571 end
572 572 when 'none'
573 573 false
574 574 when 'only_my_events'
575 575 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
576 576 true
577 577 else
578 578 false
579 579 end
580 580 when 'only_assigned'
581 581 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
582 582 true
583 583 else
584 584 false
585 585 end
586 586 when 'only_owner'
587 587 if object.is_a?(Issue) && object.author == self
588 588 true
589 589 else
590 590 false
591 591 end
592 592 else
593 593 false
594 594 end
595 595 end
596 596
597 597 def self.current=(user)
598 598 Thread.current[:current_user] = user
599 599 end
600 600
601 601 def self.current
602 602 Thread.current[:current_user] ||= User.anonymous
603 603 end
604 604
605 605 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
606 606 # one anonymous user per database.
607 607 def self.anonymous
608 608 anonymous_user = AnonymousUser.first
609 609 if anonymous_user.nil?
610 610 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
611 611 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
612 612 end
613 613 anonymous_user
614 614 end
615 615
616 616 # Salts all existing unsalted passwords
617 617 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
618 618 # This method is used in the SaltPasswords migration and is to be kept as is
619 619 def self.salt_unsalted_passwords!
620 620 transaction do
621 621 User.where("salt IS NULL OR salt = ''").find_each do |user|
622 622 next if user.hashed_password.blank?
623 623 salt = User.generate_salt
624 624 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
625 625 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
626 626 end
627 627 end
628 628 end
629 629
630 630 protected
631 631
632 632 def validate_password_length
633 633 # Password length validation based on setting
634 634 if !password.nil? && password.size < Setting.password_min_length.to_i
635 635 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
636 636 end
637 637 end
638 638
639 639 private
640 640
641 641 # Removes references that are not handled by associations
642 642 # Things that are not deleted are reassociated with the anonymous user
643 643 def remove_references_before_destroy
644 644 return if self.id.nil?
645 645
646 646 substitute = User.anonymous
647 647 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
648 648 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
649 649 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
650 650 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
651 651 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
652 652 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
653 653 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
654 654 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
655 655 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
656 656 # Remove private queries and keep public ones
657 657 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
658 658 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
659 659 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
660 660 Token.delete_all ['user_id = ?', id]
661 661 Watcher.delete_all ['user_id = ?', id]
662 662 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
663 663 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
664 664 end
665 665
666 666 # Return password digest
667 667 def self.hash_password(clear_password)
668 668 Digest::SHA1.hexdigest(clear_password || "")
669 669 end
670 670
671 671 # Returns a 128bits random salt as a hex string (32 chars long)
672 672 def self.generate_salt
673 673 Redmine::Utils.random_hex(16)
674 674 end
675 675
676 676 end
677 677
678 678 class AnonymousUser < User
679 679 validate :validate_anonymous_uniqueness, :on => :create
680 680
681 681 def validate_anonymous_uniqueness
682 682 # There should be only one AnonymousUser in the database
683 683 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
684 684 end
685 685
686 686 def available_custom_fields
687 687 []
688 688 end
689 689
690 690 # Overrides a few properties
691 691 def logged?; false end
692 692 def admin; false end
693 693 def name(*args); I18n.t(:label_user_anonymous) end
694 694 def mail; nil end
695 695 def time_zone; nil end
696 696 def rss_key; nil end
697 697
698 698 def pref
699 699 UserPreference.new(:user => self)
700 700 end
701 701
702 702 # Anonymous user can not be destroyed
703 703 def destroy
704 704 false
705 705 end
706 706 end
@@ -1,284 +1,284
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Version < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 after_update :update_issues_from_sharing_change
21 21 belongs_to :project
22 22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
23 23 acts_as_customizable
24 24 acts_as_attachable :view_permission => :view_files,
25 25 :delete_permission => :manage_files
26 26
27 27 VERSION_STATUSES = %w(open locked closed)
28 28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29 29
30 30 validates_presence_of :name
31 31 validates_uniqueness_of :name, :scope => [:project_id]
32 32 validates_length_of :name, :maximum => 60
33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
33 validates_format_of :effective_date, :with => /\A\d{4}-\d{2}-\d{2}\z/, :message => :not_a_date, :allow_nil => true
34 34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36 36 validate :validate_version
37 37
38 38 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
39 39 scope :open, lambda { where(:status => 'open') }
40 40 scope :visible, lambda {|*args|
41 41 includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
42 42 }
43 43
44 44 safe_attributes 'name',
45 45 'description',
46 46 'effective_date',
47 47 'due_date',
48 48 'wiki_page_title',
49 49 'status',
50 50 'sharing',
51 51 'custom_field_values'
52 52
53 53 # Returns true if +user+ or current user is allowed to view the version
54 54 def visible?(user=User.current)
55 55 user.allowed_to?(:view_issues, self.project)
56 56 end
57 57
58 58 # Version files have same visibility as project files
59 59 def attachments_visible?(*args)
60 60 project.present? && project.attachments_visible?(*args)
61 61 end
62 62
63 63 def start_date
64 64 @start_date ||= fixed_issues.minimum('start_date')
65 65 end
66 66
67 67 def due_date
68 68 effective_date
69 69 end
70 70
71 71 def due_date=(arg)
72 72 self.effective_date=(arg)
73 73 end
74 74
75 75 # Returns the total estimated time for this version
76 76 # (sum of leaves estimated_hours)
77 77 def estimated_hours
78 78 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
79 79 end
80 80
81 81 # Returns the total reported time for this version
82 82 def spent_hours
83 83 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
84 84 end
85 85
86 86 def closed?
87 87 status == 'closed'
88 88 end
89 89
90 90 def open?
91 91 status == 'open'
92 92 end
93 93
94 94 # Returns true if the version is completed: due date reached and no open issues
95 95 def completed?
96 96 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
97 97 end
98 98
99 99 def behind_schedule?
100 100 if completed_pourcent == 100
101 101 return false
102 102 elsif due_date && start_date
103 103 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
104 104 return done_date <= Date.today
105 105 else
106 106 false # No issues so it's not late
107 107 end
108 108 end
109 109
110 110 # Returns the completion percentage of this version based on the amount of open/closed issues
111 111 # and the time spent on the open issues.
112 112 def completed_pourcent
113 113 if issues_count == 0
114 114 0
115 115 elsif open_issues_count == 0
116 116 100
117 117 else
118 118 issues_progress(false) + issues_progress(true)
119 119 end
120 120 end
121 121
122 122 # Returns the percentage of issues that have been marked as 'closed'.
123 123 def closed_pourcent
124 124 if issues_count == 0
125 125 0
126 126 else
127 127 issues_progress(false)
128 128 end
129 129 end
130 130
131 131 # Returns true if the version is overdue: due date reached and some open issues
132 132 def overdue?
133 133 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
134 134 end
135 135
136 136 # Returns assigned issues count
137 137 def issues_count
138 138 load_issue_counts
139 139 @issue_count
140 140 end
141 141
142 142 # Returns the total amount of open issues for this version.
143 143 def open_issues_count
144 144 load_issue_counts
145 145 @open_issues_count
146 146 end
147 147
148 148 # Returns the total amount of closed issues for this version.
149 149 def closed_issues_count
150 150 load_issue_counts
151 151 @closed_issues_count
152 152 end
153 153
154 154 def wiki_page
155 155 if project.wiki && !wiki_page_title.blank?
156 156 @wiki_page ||= project.wiki.find_page(wiki_page_title)
157 157 end
158 158 @wiki_page
159 159 end
160 160
161 161 def to_s; name end
162 162
163 163 def to_s_with_project
164 164 "#{project} - #{name}"
165 165 end
166 166
167 167 # Versions are sorted by effective_date and name
168 168 # Those with no effective_date are at the end, sorted by name
169 169 def <=>(version)
170 170 if self.effective_date
171 171 if version.effective_date
172 172 if self.effective_date == version.effective_date
173 173 name == version.name ? id <=> version.id : name <=> version.name
174 174 else
175 175 self.effective_date <=> version.effective_date
176 176 end
177 177 else
178 178 -1
179 179 end
180 180 else
181 181 if version.effective_date
182 182 1
183 183 else
184 184 name == version.name ? id <=> version.id : name <=> version.name
185 185 end
186 186 end
187 187 end
188 188
189 189 def self.fields_for_order_statement(table=nil)
190 190 table ||= table_name
191 191 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
192 192 end
193 193
194 194 scope :sorted, order(fields_for_order_statement)
195 195
196 196 # Returns the sharings that +user+ can set the version to
197 197 def allowed_sharings(user = User.current)
198 198 VERSION_SHARINGS.select do |s|
199 199 if sharing == s
200 200 true
201 201 else
202 202 case s
203 203 when 'system'
204 204 # Only admin users can set a systemwide sharing
205 205 user.admin?
206 206 when 'hierarchy', 'tree'
207 207 # Only users allowed to manage versions of the root project can
208 208 # set sharing to hierarchy or tree
209 209 project.nil? || user.allowed_to?(:manage_versions, project.root)
210 210 else
211 211 true
212 212 end
213 213 end
214 214 end
215 215 end
216 216
217 217 private
218 218
219 219 def load_issue_counts
220 220 unless @issue_count
221 221 @open_issues_count = 0
222 222 @closed_issues_count = 0
223 223 fixed_issues.count(:all, :group => :status).each do |status, count|
224 224 if status.is_closed?
225 225 @closed_issues_count += count
226 226 else
227 227 @open_issues_count += count
228 228 end
229 229 end
230 230 @issue_count = @open_issues_count + @closed_issues_count
231 231 end
232 232 end
233 233
234 234 # Update the issue's fixed versions. Used if a version's sharing changes.
235 235 def update_issues_from_sharing_change
236 236 if sharing_changed?
237 237 if VERSION_SHARINGS.index(sharing_was).nil? ||
238 238 VERSION_SHARINGS.index(sharing).nil? ||
239 239 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
240 240 Issue.update_versions_from_sharing_change self
241 241 end
242 242 end
243 243 end
244 244
245 245 # Returns the average estimated time of assigned issues
246 246 # or 1 if no issue has an estimated time
247 247 # Used to weigth unestimated issues in progress calculation
248 248 def estimated_average
249 249 if @estimated_average.nil?
250 250 average = fixed_issues.average(:estimated_hours).to_f
251 251 if average == 0
252 252 average = 1
253 253 end
254 254 @estimated_average = average
255 255 end
256 256 @estimated_average
257 257 end
258 258
259 259 # Returns the total progress of open or closed issues. The returned percentage takes into account
260 260 # the amount of estimated time set for this version.
261 261 #
262 262 # Examples:
263 263 # issues_progress(true) => returns the progress percentage for open issues.
264 264 # issues_progress(false) => returns the progress percentage for closed issues.
265 265 def issues_progress(open)
266 266 @issues_progress ||= {}
267 267 @issues_progress[open] ||= begin
268 268 progress = 0
269 269 if issues_count > 0
270 270 ratio = open ? 'done_ratio' : 100
271 271
272 272 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
273 273 progress = done / (estimated_average * issues_count)
274 274 end
275 275 progress
276 276 end
277 277 end
278 278
279 279 def validate_version
280 280 if effective_date.nil? && @attributes['effective_date'].present?
281 281 errors.add :effective_date, :not_a_date
282 282 end
283 283 end
284 284 end
@@ -1,97 +1,97
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Wiki < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 belongs_to :project
21 21 has_many :pages, :class_name => 'WikiPage', :dependent => :destroy, :order => 'title'
22 22 has_many :redirects, :class_name => 'WikiRedirect', :dependent => :delete_all
23 23
24 24 acts_as_watchable
25 25
26 26 validates_presence_of :start_page
27 validates_format_of :start_page, :with => /^[^,\.\/\?\;\|\:]*$/
27 validates_format_of :start_page, :with => /\A[^,\.\/\?\;\|\:]*\z/
28 28
29 29 safe_attributes 'start_page'
30 30
31 31 def visible?(user=User.current)
32 32 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
33 33 end
34 34
35 35 # Returns the wiki page that acts as the sidebar content
36 36 # or nil if no such page exists
37 37 def sidebar
38 38 @sidebar ||= find_page('Sidebar', :with_redirect => false)
39 39 end
40 40
41 41 # find the page with the given title
42 42 # if page doesn't exist, return a new page
43 43 def find_or_new_page(title)
44 44 title = start_page if title.blank?
45 45 find_page(title) || WikiPage.new(:wiki => self, :title => Wiki.titleize(title))
46 46 end
47 47
48 48 # find the page with the given title
49 49 def find_page(title, options = {})
50 50 @page_found_with_redirect = false
51 51 title = start_page if title.blank?
52 52 title = Wiki.titleize(title)
53 53 page = pages.first(:conditions => ["LOWER(title) = LOWER(?)", title])
54 54 if !page && !(options[:with_redirect] == false)
55 55 # search for a redirect
56 56 redirect = redirects.first(:conditions => ["LOWER(title) = LOWER(?)", title])
57 57 if redirect
58 58 page = find_page(redirect.redirects_to, :with_redirect => false)
59 59 @page_found_with_redirect = true
60 60 end
61 61 end
62 62 page
63 63 end
64 64
65 65 # Returns true if the last page was found with a redirect
66 66 def page_found_with_redirect?
67 67 @page_found_with_redirect
68 68 end
69 69
70 70 # Finds a page by title
71 71 # The given string can be of one of the forms: "title" or "project:title"
72 72 # Examples:
73 73 # Wiki.find_page("bar", project => foo)
74 74 # Wiki.find_page("foo:bar")
75 75 def self.find_page(title, options = {})
76 76 project = options[:project]
77 77 if title.to_s =~ %r{^([^\:]+)\:(.*)$}
78 78 project_identifier, title = $1, $2
79 79 project = Project.find_by_identifier(project_identifier) || Project.find_by_name(project_identifier)
80 80 end
81 81 if project && project.wiki
82 82 page = project.wiki.find_page(title)
83 83 if page && page.content
84 84 page
85 85 end
86 86 end
87 87 end
88 88
89 89 # turn a string into a valid page title
90 90 def self.titleize(title)
91 91 # replace spaces with _ and remove unwanted caracters
92 92 title = title.gsub(/\s+/, '_').delete(',./?;|:') if title
93 93 # upcase the first letter
94 94 title = (title.slice(0..0).upcase + (title.slice(1..-1) || '')) if title
95 95 title
96 96 end
97 97 end
General Comments 0
You need to be logged in to leave comments. Login now