##// END OF EJS Templates
model: replace Rails2 "named_scope" to Rails3 "scope"...
Toshi MARUYAMA -
r9355:d0d01d4e704b
parent child
Show More
@@ -1,56 +1,56
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Board < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 belongs_to :project
21 21 has_many :topics, :class_name => 'Message', :conditions => "#{Message.table_name}.parent_id IS NULL", :order => "#{Message.table_name}.created_on DESC"
22 22 has_many :messages, :dependent => :destroy, :order => "#{Message.table_name}.created_on DESC"
23 23 belongs_to :last_message, :class_name => 'Message', :foreign_key => :last_message_id
24 24 acts_as_list :scope => :project_id
25 25 acts_as_watchable
26 26
27 27 validates_presence_of :name, :description
28 28 validates_length_of :name, :maximum => 30
29 29 validates_length_of :description, :maximum => 255
30 30
31 named_scope :visible, lambda {|*args| { :include => :project,
31 scope :visible, lambda {|*args| { :include => :project,
32 32 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
33 33
34 34 safe_attributes 'name', 'description', 'move_to'
35 35
36 36 def visible?(user=User.current)
37 37 !user.nil? && user.allowed_to?(:view_messages, project)
38 38 end
39 39
40 40 def to_s
41 41 name
42 42 end
43 43
44 44 def reset_counters!
45 45 self.class.reset_counters!(id)
46 46 end
47 47
48 48 # Updates topics_count, messages_count and last_message_id attributes for +board_id+
49 49 def self.reset_counters!(board_id)
50 50 board_id = board_id.to_i
51 51 update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
52 52 " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
53 53 " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})",
54 54 ["id = ?", board_id])
55 55 end
56 56 end
@@ -1,285 +1,286
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'iconv'
19 19
20 20 class Changeset < ActiveRecord::Base
21 21 belongs_to :repository
22 22 belongs_to :user
23 23 has_many :changes, :dependent => :delete_all
24 24 has_and_belongs_to_many :issues
25 25 has_and_belongs_to_many :parents,
26 26 :class_name => "Changeset",
27 27 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
28 28 :association_foreign_key => 'parent_id', :foreign_key => 'changeset_id'
29 29 has_and_belongs_to_many :children,
30 30 :class_name => "Changeset",
31 31 :join_table => "#{table_name_prefix}changeset_parents#{table_name_suffix}",
32 32 :association_foreign_key => 'changeset_id', :foreign_key => 'parent_id'
33 33
34 34 acts_as_event :title => Proc.new {|o| o.title},
35 35 :description => :long_comments,
36 36 :datetime => :committed_on,
37 37 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :repository_id => o.repository.identifier_param, :rev => o.identifier}}
38 38
39 39 acts_as_searchable :columns => 'comments',
40 40 :include => {:repository => :project},
41 41 :project_key => "#{Repository.table_name}.project_id",
42 42 :date_column => 'committed_on'
43 43
44 44 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
45 45 :author_key => :user_id,
46 46 :find_options => {:include => [:user, {:repository => :project}]}
47 47
48 48 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
49 49 validates_uniqueness_of :revision, :scope => :repository_id
50 50 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
51 51
52 named_scope :visible, lambda {|*args| { :include => {:repository => :project},
52 scope :visible,
53 lambda {|*args| { :include => {:repository => :project},
53 54 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
54 55
55 56 after_create :scan_for_issues
56 57 before_create :before_create_cs
57 58
58 59 def revision=(r)
59 60 write_attribute :revision, (r.nil? ? nil : r.to_s)
60 61 end
61 62
62 63 # Returns the identifier of this changeset; depending on repository backends
63 64 def identifier
64 65 if repository.class.respond_to? :changeset_identifier
65 66 repository.class.changeset_identifier self
66 67 else
67 68 revision.to_s
68 69 end
69 70 end
70 71
71 72 def committed_on=(date)
72 73 self.commit_date = date
73 74 super
74 75 end
75 76
76 77 # Returns the readable identifier
77 78 def format_identifier
78 79 if repository.class.respond_to? :format_changeset_identifier
79 80 repository.class.format_changeset_identifier self
80 81 else
81 82 identifier
82 83 end
83 84 end
84 85
85 86 def project
86 87 repository.project
87 88 end
88 89
89 90 def author
90 91 user || committer.to_s.split('<').first
91 92 end
92 93
93 94 def before_create_cs
94 95 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
95 96 self.comments = self.class.normalize_comments(
96 97 self.comments, repository.repo_log_encoding)
97 98 self.user = repository.find_committer_user(self.committer)
98 99 end
99 100
100 101 def scan_for_issues
101 102 scan_comment_for_issue_ids
102 103 end
103 104
104 105 TIMELOG_RE = /
105 106 (
106 107 ((\d+)(h|hours?))((\d+)(m|min)?)?
107 108 |
108 109 ((\d+)(h|hours?|m|min))
109 110 |
110 111 (\d+):(\d+)
111 112 |
112 113 (\d+([\.,]\d+)?)h?
113 114 )
114 115 /x
115 116
116 117 def scan_comment_for_issue_ids
117 118 return if comments.blank?
118 119 # keywords used to reference issues
119 120 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
120 121 ref_keywords_any = ref_keywords.delete('*')
121 122 # keywords used to fix issues
122 123 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
123 124
124 125 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
125 126
126 127 referenced_issues = []
127 128
128 129 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
129 130 action, refs = match[2], match[3]
130 131 next unless action.present? || ref_keywords_any
131 132
132 133 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
133 134 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
134 135 if issue
135 136 referenced_issues << issue
136 137 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
137 138 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
138 139 end
139 140 end
140 141 end
141 142
142 143 referenced_issues.uniq!
143 144 self.issues = referenced_issues unless referenced_issues.empty?
144 145 end
145 146
146 147 def short_comments
147 148 @short_comments || split_comments.first
148 149 end
149 150
150 151 def long_comments
151 152 @long_comments || split_comments.last
152 153 end
153 154
154 155 def text_tag(ref_project=nil)
155 156 tag = if scmid?
156 157 "commit:#{scmid}"
157 158 else
158 159 "r#{revision}"
159 160 end
160 161 if repository && repository.identifier.present?
161 162 tag = "#{repository.identifier}|#{tag}"
162 163 end
163 164 if ref_project && project && ref_project != project
164 165 tag = "#{project.identifier}:#{tag}"
165 166 end
166 167 tag
167 168 end
168 169
169 170 # Returns the title used for the changeset in the activity/search results
170 171 def title
171 172 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
172 173 comm = short_comments.blank? ? '' : (': ' + short_comments)
173 174 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
174 175 end
175 176
176 177 # Returns the previous changeset
177 178 def previous
178 179 @previous ||= Changeset.find(:first,
179 180 :conditions => ['id < ? AND repository_id = ?',
180 181 self.id, self.repository_id],
181 182 :order => 'id DESC')
182 183 end
183 184
184 185 # Returns the next changeset
185 186 def next
186 187 @next ||= Changeset.find(:first,
187 188 :conditions => ['id > ? AND repository_id = ?',
188 189 self.id, self.repository_id],
189 190 :order => 'id ASC')
190 191 end
191 192
192 193 # Creates a new Change from it's common parameters
193 194 def create_change(change)
194 195 Change.create(:changeset => self,
195 196 :action => change[:action],
196 197 :path => change[:path],
197 198 :from_path => change[:from_path],
198 199 :from_revision => change[:from_revision])
199 200 end
200 201
201 202 # Finds an issue that can be referenced by the commit message
202 203 def find_referenced_issue_by_id(id)
203 204 return nil if id.blank?
204 205 issue = Issue.find_by_id(id.to_i, :include => :project)
205 206 if Setting.commit_cross_project_ref?
206 207 # all issues can be referenced/fixed
207 208 elsif issue
208 209 # issue that belong to the repository project, a subproject or a parent project only
209 210 unless issue.project &&
210 211 (project == issue.project || project.is_ancestor_of?(issue.project) ||
211 212 project.is_descendant_of?(issue.project))
212 213 issue = nil
213 214 end
214 215 end
215 216 issue
216 217 end
217 218
218 219 private
219 220
220 221 def fix_issue(issue)
221 222 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
222 223 if status.nil?
223 224 logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
224 225 return issue
225 226 end
226 227
227 228 # the issue may have been updated by the closure of another one (eg. duplicate)
228 229 issue.reload
229 230 # don't change the status is the issue is closed
230 231 return if issue.status && issue.status.is_closed?
231 232
232 233 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project)))
233 234 issue.status = status
234 235 unless Setting.commit_fix_done_ratio.blank?
235 236 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
236 237 end
237 238 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
238 239 { :changeset => self, :issue => issue })
239 240 unless issue.save
240 241 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
241 242 end
242 243 issue
243 244 end
244 245
245 246 def log_time(issue, hours)
246 247 time_entry = TimeEntry.new(
247 248 :user => user,
248 249 :hours => hours,
249 250 :issue => issue,
250 251 :spent_on => commit_date,
251 252 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
252 253 :locale => Setting.default_language)
253 254 )
254 255 time_entry.activity = log_time_activity unless log_time_activity.nil?
255 256
256 257 unless time_entry.save
257 258 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
258 259 end
259 260 time_entry
260 261 end
261 262
262 263 def log_time_activity
263 264 if Setting.commit_logtime_activity_id.to_i > 0
264 265 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
265 266 end
266 267 end
267 268
268 269 def split_comments
269 270 comments =~ /\A(.+?)\r?\n(.*)$/m
270 271 @short_comments = $1 || comments
271 272 @long_comments = $2.to_s.strip
272 273 return @short_comments, @long_comments
273 274 end
274 275
275 276 public
276 277
277 278 # Strips and reencodes a commit log before insertion into the database
278 279 def self.normalize_comments(str, encoding)
279 280 Changeset.to_utf8(str.to_s.strip, encoding)
280 281 end
281 282
282 283 def self.to_utf8(str, encoding)
283 284 Redmine::CodesetUtil.to_utf8(str, encoding)
284 285 end
285 286 end
@@ -1,56 +1,56
1 1 # RedMine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Document < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 belongs_to :project
21 21 belongs_to :category, :class_name => "DocumentCategory", :foreign_key => "category_id"
22 22 acts_as_attachable :delete_permission => :manage_documents
23 23
24 24 acts_as_searchable :columns => ['title', "#{table_name}.description"], :include => :project
25 25 acts_as_event :title => Proc.new {|o| "#{l(:label_document)}: #{o.title}"},
26 26 :author => Proc.new {|o| (a = o.attachments.find(:first, :order => "#{Attachment.table_name}.created_on ASC")) ? a.author : nil },
27 27 :url => Proc.new {|o| {:controller => 'documents', :action => 'show', :id => o.id}}
28 28 acts_as_activity_provider :find_options => {:include => :project}
29 29
30 30 validates_presence_of :project, :title, :category
31 31 validates_length_of :title, :maximum => 60
32 32
33 named_scope :visible, lambda {|*args| { :include => :project,
33 scope :visible, lambda {|*args| { :include => :project,
34 34 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
35 35
36 36 safe_attributes 'category_id', 'title', 'description'
37 37
38 38 def visible?(user=User.current)
39 39 !user.nil? && user.allowed_to?(:view_documents, project)
40 40 end
41 41
42 42 def initialize(attributes=nil, *args)
43 43 super
44 44 if new_record?
45 45 self.category ||= DocumentCategory.default
46 46 end
47 47 end
48 48
49 49 def updated_on
50 50 unless @updated_on
51 51 a = attachments.last
52 52 @updated_on = (a && a.created_on) || created_on
53 53 end
54 54 @updated_on
55 55 end
56 56 end
@@ -1,140 +1,140
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Enumeration < ActiveRecord::Base
19 19 include Redmine::SubclassFactory
20 20
21 21 default_scope :order => "#{Enumeration.table_name}.position ASC"
22 22
23 23 belongs_to :project
24 24
25 25 acts_as_list :scope => 'type = \'#{type}\''
26 26 acts_as_customizable
27 27 acts_as_tree :order => 'position ASC'
28 28
29 29 before_destroy :check_integrity
30 30 before_save :check_default
31 31
32 32 attr_protected :type
33 33
34 34 validates_presence_of :name
35 35 validates_uniqueness_of :name, :scope => [:type, :project_id]
36 36 validates_length_of :name, :maximum => 30
37 37
38 named_scope :shared, :conditions => { :project_id => nil }
39 named_scope :active, :conditions => { :active => true }
40 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
38 scope :shared, :conditions => { :project_id => nil }
39 scope :active, :conditions => { :active => true }
40 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
41 41
42 42 def self.default
43 43 # Creates a fake default scope so Enumeration.default will check
44 44 # it's type. STI subclasses will automatically add their own
45 45 # types to the finder.
46 46 if self.descends_from_active_record?
47 47 find(:first, :conditions => { :is_default => true, :type => 'Enumeration' })
48 48 else
49 49 # STI classes are
50 50 find(:first, :conditions => { :is_default => true })
51 51 end
52 52 end
53 53
54 54 # Overloaded on concrete classes
55 55 def option_name
56 56 nil
57 57 end
58 58
59 59 def check_default
60 60 if is_default? && is_default_changed?
61 61 Enumeration.update_all("is_default = #{connection.quoted_false}", {:type => type})
62 62 end
63 63 end
64 64
65 65 # Overloaded on concrete classes
66 66 def objects_count
67 67 0
68 68 end
69 69
70 70 def in_use?
71 71 self.objects_count != 0
72 72 end
73 73
74 74 # Is this enumeration overiding a system level enumeration?
75 75 def is_override?
76 76 !self.parent.nil?
77 77 end
78 78
79 79 alias :destroy_without_reassign :destroy
80 80
81 81 # Destroy the enumeration
82 82 # If a enumeration is specified, objects are reassigned
83 83 def destroy(reassign_to = nil)
84 84 if reassign_to && reassign_to.is_a?(Enumeration)
85 85 self.transfer_relations(reassign_to)
86 86 end
87 87 destroy_without_reassign
88 88 end
89 89
90 90 def <=>(enumeration)
91 91 position <=> enumeration.position
92 92 end
93 93
94 94 def to_s; name end
95 95
96 96 # Returns the Subclasses of Enumeration. Each Subclass needs to be
97 97 # required in development mode.
98 98 #
99 99 # Note: subclasses is protected in ActiveRecord
100 100 def self.get_subclasses
101 101 subclasses
102 102 end
103 103
104 104 # Does the +new+ Hash override the previous Enumeration?
105 105 def self.overridding_change?(new, previous)
106 106 if (same_active_state?(new['active'], previous.active)) && same_custom_values?(new,previous)
107 107 return false
108 108 else
109 109 return true
110 110 end
111 111 end
112 112
113 113 # Does the +new+ Hash have the same custom values as the previous Enumeration?
114 114 def self.same_custom_values?(new, previous)
115 115 previous.custom_field_values.each do |custom_value|
116 116 if custom_value.value != new["custom_field_values"][custom_value.custom_field_id.to_s]
117 117 return false
118 118 end
119 119 end
120 120
121 121 return true
122 122 end
123 123
124 124 # Are the new and previous fields equal?
125 125 def self.same_active_state?(new, previous)
126 126 new = (new == "1" ? true : false)
127 127 return new == previous
128 128 end
129 129
130 130 private
131 131 def check_integrity
132 132 raise "Can't delete enumeration" if self.in_use?
133 133 end
134 134
135 135 end
136 136
137 137 # Force load the subclasses in development mode
138 138 require_dependency 'time_entry_activity'
139 139 require_dependency 'document_category'
140 140 require_dependency 'issue_priority'
@@ -1,1078 +1,1079
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Issue < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 belongs_to :project
22 22 belongs_to :tracker
23 23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 25 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 29
30 30 has_many :journals, :as => :journalized, :dependent => :destroy
31 31 has_many :time_entries, :dependent => :delete_all
32 32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33 33
34 34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36 36
37 37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 38 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
39 39 acts_as_customizable
40 40 acts_as_watchable
41 41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 42 :include => [:project, :journals],
43 43 # sort by id so that limited eager loading doesn't break with postgresql
44 44 :order_column => "#{table_name}.id"
45 45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48 48
49 49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 50 :author_key => :author_id
51 51
52 52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53 53
54 54 attr_reader :current_journal
55 55
56 56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57 57
58 58 validates_length_of :subject, :maximum => 255
59 59 validates_inclusion_of :done_ratio, :in => 0..100
60 60 validates_numericality_of :estimated_hours, :allow_nil => true
61 61 validate :validate_issue
62 62
63 named_scope :visible, lambda {|*args| { :include => :project,
63 scope :visible,
64 lambda {|*args| { :include => :project,
64 65 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
65 66
66 named_scope :open, lambda {|*args|
67 scope :open, lambda {|*args|
67 68 is_closed = args.size > 0 ? !args.first : false
68 69 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
69 70 }
70 71
71 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
72 named_scope :with_limit, lambda { |limit| { :limit => limit} }
73 named_scope :on_active_project, :include => [:status, :project, :tracker],
72 scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
73 scope :with_limit, lambda { |limit| { :limit => limit} }
74 scope :on_active_project, :include => [:status, :project, :tracker],
74 75 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
75 76
76 77 before_create :default_assign
77 78 before_save :close_duplicates, :update_done_ratio_from_issue_status
78 79 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
79 80 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
80 81 after_destroy :update_parent_attributes
81 82
82 83 # Returns a SQL conditions string used to find all issues visible by the specified user
83 84 def self.visible_condition(user, options={})
84 85 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
85 86 case role.issues_visibility
86 87 when 'all'
87 88 nil
88 89 when 'default'
89 90 user_ids = [user.id] + user.groups.map(&:id)
90 91 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
91 92 when 'own'
92 93 user_ids = [user.id] + user.groups.map(&:id)
93 94 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
94 95 else
95 96 '1=0'
96 97 end
97 98 end
98 99 end
99 100
100 101 # Returns true if usr or current user is allowed to view the issue
101 102 def visible?(usr=nil)
102 103 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
103 104 case role.issues_visibility
104 105 when 'all'
105 106 true
106 107 when 'default'
107 108 !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to)
108 109 when 'own'
109 110 self.author == user || user.is_or_belongs_to?(assigned_to)
110 111 else
111 112 false
112 113 end
113 114 end
114 115 end
115 116
116 117 def initialize(attributes=nil, *args)
117 118 super
118 119 if new_record?
119 120 # set default values for new records only
120 121 self.status ||= IssueStatus.default
121 122 self.priority ||= IssuePriority.default
122 123 self.watcher_user_ids = []
123 124 end
124 125 end
125 126
126 127 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
127 128 def available_custom_fields
128 129 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
129 130 end
130 131
131 132 # Copies attributes from another issue, arg can be an id or an Issue
132 133 def copy_from(arg, options={})
133 134 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
134 135 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
135 136 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
136 137 self.status = issue.status
137 138 self.author = User.current
138 139 unless options[:attachments] == false
139 140 self.attachments = issue.attachments.map do |attachement|
140 141 attachement.copy(:container => self)
141 142 end
142 143 end
143 144 @copied_from = issue
144 145 self
145 146 end
146 147
147 148 # Returns an unsaved copy of the issue
148 149 def copy(attributes=nil, copy_options={})
149 150 copy = self.class.new.copy_from(self, copy_options)
150 151 copy.attributes = attributes if attributes
151 152 copy
152 153 end
153 154
154 155 # Returns true if the issue is a copy
155 156 def copy?
156 157 @copied_from.present?
157 158 end
158 159
159 160 # Moves/copies an issue to a new project and tracker
160 161 # Returns the moved/copied issue on success, false on failure
161 162 def move_to_project(new_project, new_tracker=nil, options={})
162 163 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
163 164
164 165 if options[:copy]
165 166 issue = self.copy
166 167 else
167 168 issue = self
168 169 end
169 170
170 171 issue.init_journal(User.current, options[:notes])
171 172
172 173 # Preserve previous behaviour
173 174 # #move_to_project doesn't change tracker automatically
174 175 issue.send :project=, new_project, true
175 176 if new_tracker
176 177 issue.tracker = new_tracker
177 178 end
178 179 # Allow bulk setting of attributes on the issue
179 180 if options[:attributes]
180 181 issue.attributes = options[:attributes]
181 182 end
182 183
183 184 issue.save ? issue : false
184 185 end
185 186
186 187 def status_id=(sid)
187 188 self.status = nil
188 189 write_attribute(:status_id, sid)
189 190 end
190 191
191 192 def priority_id=(pid)
192 193 self.priority = nil
193 194 write_attribute(:priority_id, pid)
194 195 end
195 196
196 197 def category_id=(cid)
197 198 self.category = nil
198 199 write_attribute(:category_id, cid)
199 200 end
200 201
201 202 def fixed_version_id=(vid)
202 203 self.fixed_version = nil
203 204 write_attribute(:fixed_version_id, vid)
204 205 end
205 206
206 207 def tracker_id=(tid)
207 208 self.tracker = nil
208 209 result = write_attribute(:tracker_id, tid)
209 210 @custom_field_values = nil
210 211 result
211 212 end
212 213
213 214 def project_id=(project_id)
214 215 if project_id.to_s != self.project_id.to_s
215 216 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
216 217 end
217 218 end
218 219
219 220 def project=(project, keep_tracker=false)
220 221 project_was = self.project
221 222 write_attribute(:project_id, project ? project.id : nil)
222 223 association_instance_set('project', project)
223 224 if project_was && project && project_was != project
224 225 unless keep_tracker || project.trackers.include?(tracker)
225 226 self.tracker = project.trackers.first
226 227 end
227 228 # Reassign to the category with same name if any
228 229 if category
229 230 self.category = project.issue_categories.find_by_name(category.name)
230 231 end
231 232 # Keep the fixed_version if it's still valid in the new_project
232 233 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
233 234 self.fixed_version = nil
234 235 end
235 236 if parent && parent.project_id != project_id
236 237 self.parent_issue_id = nil
237 238 end
238 239 @custom_field_values = nil
239 240 end
240 241 end
241 242
242 243 def description=(arg)
243 244 if arg.is_a?(String)
244 245 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
245 246 end
246 247 write_attribute(:description, arg)
247 248 end
248 249
249 250 # Overrides assign_attributes so that project and tracker get assigned first
250 251 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
251 252 return if new_attributes.nil?
252 253 attrs = new_attributes.dup
253 254 attrs.stringify_keys!
254 255
255 256 %w(project project_id tracker tracker_id).each do |attr|
256 257 if attrs.has_key?(attr)
257 258 send "#{attr}=", attrs.delete(attr)
258 259 end
259 260 end
260 261 send :assign_attributes_without_project_and_tracker_first, attrs, *args
261 262 end
262 263 # Do not redefine alias chain on reload (see #4838)
263 264 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
264 265
265 266 def estimated_hours=(h)
266 267 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
267 268 end
268 269
269 270 safe_attributes 'project_id',
270 271 :if => lambda {|issue, user|
271 272 if issue.new_record?
272 273 issue.copy?
273 274 elsif user.allowed_to?(:move_issues, issue.project)
274 275 projects = Issue.allowed_target_projects_on_move(user)
275 276 projects.include?(issue.project) && projects.size > 1
276 277 end
277 278 }
278 279
279 280 safe_attributes 'tracker_id',
280 281 'status_id',
281 282 'category_id',
282 283 'assigned_to_id',
283 284 'priority_id',
284 285 'fixed_version_id',
285 286 'subject',
286 287 'description',
287 288 'start_date',
288 289 'due_date',
289 290 'done_ratio',
290 291 'estimated_hours',
291 292 'custom_field_values',
292 293 'custom_fields',
293 294 'lock_version',
294 295 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
295 296
296 297 safe_attributes 'status_id',
297 298 'assigned_to_id',
298 299 'fixed_version_id',
299 300 'done_ratio',
300 301 'lock_version',
301 302 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
302 303
303 304 safe_attributes 'watcher_user_ids',
304 305 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
305 306
306 307 safe_attributes 'is_private',
307 308 :if => lambda {|issue, user|
308 309 user.allowed_to?(:set_issues_private, issue.project) ||
309 310 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
310 311 }
311 312
312 313 safe_attributes 'parent_issue_id',
313 314 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
314 315 user.allowed_to?(:manage_subtasks, issue.project)}
315 316
316 317 # Safely sets attributes
317 318 # Should be called from controllers instead of #attributes=
318 319 # attr_accessible is too rough because we still want things like
319 320 # Issue.new(:project => foo) to work
320 321 def safe_attributes=(attrs, user=User.current)
321 322 return unless attrs.is_a?(Hash)
322 323
323 324 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
324 325 attrs = delete_unsafe_attributes(attrs, user)
325 326 return if attrs.empty?
326 327
327 328 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
328 329 if p = attrs.delete('project_id')
329 330 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
330 331 self.project_id = p
331 332 end
332 333 end
333 334
334 335 if t = attrs.delete('tracker_id')
335 336 self.tracker_id = t
336 337 end
337 338
338 339 if attrs['status_id']
339 340 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
340 341 attrs.delete('status_id')
341 342 end
342 343 end
343 344
344 345 unless leaf?
345 346 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
346 347 end
347 348
348 349 if attrs['parent_issue_id'].present?
349 350 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
350 351 end
351 352
352 353 # mass-assignment security bypass
353 354 assign_attributes attrs, :without_protection => true
354 355 end
355 356
356 357 def done_ratio
357 358 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
358 359 status.default_done_ratio
359 360 else
360 361 read_attribute(:done_ratio)
361 362 end
362 363 end
363 364
364 365 def self.use_status_for_done_ratio?
365 366 Setting.issue_done_ratio == 'issue_status'
366 367 end
367 368
368 369 def self.use_field_for_done_ratio?
369 370 Setting.issue_done_ratio == 'issue_field'
370 371 end
371 372
372 373 def validate_issue
373 374 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
374 375 errors.add :due_date, :not_a_date
375 376 end
376 377
377 378 if self.due_date and self.start_date and self.due_date < self.start_date
378 379 errors.add :due_date, :greater_than_start_date
379 380 end
380 381
381 382 if start_date && soonest_start && start_date < soonest_start
382 383 errors.add :start_date, :invalid
383 384 end
384 385
385 386 if fixed_version
386 387 if !assignable_versions.include?(fixed_version)
387 388 errors.add :fixed_version_id, :inclusion
388 389 elsif reopened? && fixed_version.closed?
389 390 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
390 391 end
391 392 end
392 393
393 394 # Checks that the issue can not be added/moved to a disabled tracker
394 395 if project && (tracker_id_changed? || project_id_changed?)
395 396 unless project.trackers.include?(tracker)
396 397 errors.add :tracker_id, :inclusion
397 398 end
398 399 end
399 400
400 401 # Checks parent issue assignment
401 402 if @parent_issue
402 403 if @parent_issue.project_id != project_id
403 404 errors.add :parent_issue_id, :not_same_project
404 405 elsif !new_record?
405 406 # moving an existing issue
406 407 if @parent_issue.root_id != root_id
407 408 # we can always move to another tree
408 409 elsif move_possible?(@parent_issue)
409 410 # move accepted inside tree
410 411 else
411 412 errors.add :parent_issue_id, :not_a_valid_parent
412 413 end
413 414 end
414 415 end
415 416 end
416 417
417 418 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
418 419 # even if the user turns off the setting later
419 420 def update_done_ratio_from_issue_status
420 421 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
421 422 self.done_ratio = status.default_done_ratio
422 423 end
423 424 end
424 425
425 426 def init_journal(user, notes = "")
426 427 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
427 428 if new_record?
428 429 @current_journal.notify = false
429 430 else
430 431 @attributes_before_change = attributes.dup
431 432 @custom_values_before_change = {}
432 433 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
433 434 end
434 435 # Make sure updated_on is updated when adding a note.
435 436 updated_on_will_change!
436 437 @current_journal
437 438 end
438 439
439 440 # Returns the id of the last journal or nil
440 441 def last_journal_id
441 442 if new_record?
442 443 nil
443 444 else
444 445 journals.first(:order => "#{Journal.table_name}.id DESC").try(:id)
445 446 end
446 447 end
447 448
448 449 # Return true if the issue is closed, otherwise false
449 450 def closed?
450 451 self.status.is_closed?
451 452 end
452 453
453 454 # Return true if the issue is being reopened
454 455 def reopened?
455 456 if !new_record? && status_id_changed?
456 457 status_was = IssueStatus.find_by_id(status_id_was)
457 458 status_new = IssueStatus.find_by_id(status_id)
458 459 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
459 460 return true
460 461 end
461 462 end
462 463 false
463 464 end
464 465
465 466 # Return true if the issue is being closed
466 467 def closing?
467 468 if !new_record? && status_id_changed?
468 469 status_was = IssueStatus.find_by_id(status_id_was)
469 470 status_new = IssueStatus.find_by_id(status_id)
470 471 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
471 472 return true
472 473 end
473 474 end
474 475 false
475 476 end
476 477
477 478 # Returns true if the issue is overdue
478 479 def overdue?
479 480 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
480 481 end
481 482
482 483 # Is the amount of work done less than it should for the due date
483 484 def behind_schedule?
484 485 return false if start_date.nil? || due_date.nil?
485 486 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
486 487 return done_date <= Date.today
487 488 end
488 489
489 490 # Does this issue have children?
490 491 def children?
491 492 !leaf?
492 493 end
493 494
494 495 # Users the issue can be assigned to
495 496 def assignable_users
496 497 users = project.assignable_users
497 498 users << author if author
498 499 users << assigned_to if assigned_to
499 500 users.uniq.sort
500 501 end
501 502
502 503 # Versions that the issue can be assigned to
503 504 def assignable_versions
504 505 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
505 506 end
506 507
507 508 # Returns true if this issue is blocked by another issue that is still open
508 509 def blocked?
509 510 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
510 511 end
511 512
512 513 # Returns an array of statuses that user is able to apply
513 514 def new_statuses_allowed_to(user=User.current, include_default=false)
514 515 if new_record? && @copied_from
515 516 [IssueStatus.default, @copied_from.status].compact.uniq.sort
516 517 else
517 518 initial_status = nil
518 519 if new_record?
519 520 initial_status = IssueStatus.default
520 521 elsif status_id_was
521 522 initial_status = IssueStatus.find_by_id(status_id_was)
522 523 end
523 524 initial_status ||= status
524 525
525 526 statuses = initial_status.find_new_statuses_allowed_to(
526 527 user.admin ? Role.all : user.roles_for_project(project),
527 528 tracker,
528 529 author == user,
529 530 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
530 531 )
531 532 statuses << initial_status unless statuses.empty?
532 533 statuses << IssueStatus.default if include_default
533 534 statuses = statuses.compact.uniq.sort
534 535 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
535 536 end
536 537 end
537 538
538 539 def assigned_to_was
539 540 if assigned_to_id_changed? && assigned_to_id_was.present?
540 541 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
541 542 end
542 543 end
543 544
544 545 # Returns the mail adresses of users that should be notified
545 546 def recipients
546 547 notified = []
547 548 # Author and assignee are always notified unless they have been
548 549 # locked or don't want to be notified
549 550 notified << author if author
550 551 if assigned_to
551 552 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
552 553 end
553 554 if assigned_to_was
554 555 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
555 556 end
556 557 notified = notified.select {|u| u.active? && u.notify_about?(self)}
557 558
558 559 notified += project.notified_users
559 560 notified.uniq!
560 561 # Remove users that can not view the issue
561 562 notified.reject! {|user| !visible?(user)}
562 563 notified.collect(&:mail)
563 564 end
564 565
565 566 # Returns the number of hours spent on this issue
566 567 def spent_hours
567 568 @spent_hours ||= time_entries.sum(:hours) || 0
568 569 end
569 570
570 571 # Returns the total number of hours spent on this issue and its descendants
571 572 #
572 573 # Example:
573 574 # spent_hours => 0.0
574 575 # spent_hours => 50.2
575 576 def total_spent_hours
576 577 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
577 578 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
578 579 end
579 580
580 581 def relations
581 582 @relations ||= (relations_from + relations_to).sort
582 583 end
583 584
584 585 # Preloads relations for a collection of issues
585 586 def self.load_relations(issues)
586 587 if issues.any?
587 588 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
588 589 issues.each do |issue|
589 590 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
590 591 end
591 592 end
592 593 end
593 594
594 595 # Preloads visible spent time for a collection of issues
595 596 def self.load_visible_spent_hours(issues, user=User.current)
596 597 if issues.any?
597 598 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
598 599 issues.each do |issue|
599 600 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
600 601 end
601 602 end
602 603 end
603 604
604 605 # Finds an issue relation given its id.
605 606 def find_relation(relation_id)
606 607 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
607 608 end
608 609
609 610 def all_dependent_issues(except=[])
610 611 except << self
611 612 dependencies = []
612 613 relations_from.each do |relation|
613 614 if relation.issue_to && !except.include?(relation.issue_to)
614 615 dependencies << relation.issue_to
615 616 dependencies += relation.issue_to.all_dependent_issues(except)
616 617 end
617 618 end
618 619 dependencies
619 620 end
620 621
621 622 # Returns an array of issues that duplicate this one
622 623 def duplicates
623 624 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
624 625 end
625 626
626 627 # Returns the due date or the target due date if any
627 628 # Used on gantt chart
628 629 def due_before
629 630 due_date || (fixed_version ? fixed_version.effective_date : nil)
630 631 end
631 632
632 633 # Returns the time scheduled for this issue.
633 634 #
634 635 # Example:
635 636 # Start Date: 2/26/09, End Date: 3/04/09
636 637 # duration => 6
637 638 def duration
638 639 (start_date && due_date) ? due_date - start_date : 0
639 640 end
640 641
641 642 def soonest_start
642 643 @soonest_start ||= (
643 644 relations_to.collect{|relation| relation.successor_soonest_start} +
644 645 ancestors.collect(&:soonest_start)
645 646 ).compact.max
646 647 end
647 648
648 649 def reschedule_after(date)
649 650 return if date.nil?
650 651 if leaf?
651 652 if start_date.nil? || start_date < date
652 653 self.start_date, self.due_date = date, date + duration
653 654 begin
654 655 save
655 656 rescue ActiveRecord::StaleObjectError
656 657 reload
657 658 self.start_date, self.due_date = date, date + duration
658 659 save
659 660 end
660 661 end
661 662 else
662 663 leaves.each do |leaf|
663 664 leaf.reschedule_after(date)
664 665 end
665 666 end
666 667 end
667 668
668 669 def <=>(issue)
669 670 if issue.nil?
670 671 -1
671 672 elsif root_id != issue.root_id
672 673 (root_id || 0) <=> (issue.root_id || 0)
673 674 else
674 675 (lft || 0) <=> (issue.lft || 0)
675 676 end
676 677 end
677 678
678 679 def to_s
679 680 "#{tracker} ##{id}: #{subject}"
680 681 end
681 682
682 683 # Returns a string of css classes that apply to the issue
683 684 def css_classes
684 685 s = "issue status-#{status.position} priority-#{priority.position}"
685 686 s << ' closed' if closed?
686 687 s << ' overdue' if overdue?
687 688 s << ' child' if child?
688 689 s << ' parent' unless leaf?
689 690 s << ' private' if is_private?
690 691 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
691 692 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
692 693 s
693 694 end
694 695
695 696 # Saves an issue and a time_entry from the parameters
696 697 def save_issue_with_child_records(params, existing_time_entry=nil)
697 698 Issue.transaction do
698 699 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
699 700 @time_entry = existing_time_entry || TimeEntry.new
700 701 @time_entry.project = project
701 702 @time_entry.issue = self
702 703 @time_entry.user = User.current
703 704 @time_entry.spent_on = User.current.today
704 705 @time_entry.attributes = params[:time_entry]
705 706 self.time_entries << @time_entry
706 707 end
707 708
708 709 # TODO: Rename hook
709 710 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
710 711 if save
711 712 # TODO: Rename hook
712 713 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
713 714 else
714 715 raise ActiveRecord::Rollback
715 716 end
716 717 end
717 718 end
718 719
719 720 # Unassigns issues from +version+ if it's no longer shared with issue's project
720 721 def self.update_versions_from_sharing_change(version)
721 722 # Update issues assigned to the version
722 723 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
723 724 end
724 725
725 726 # Unassigns issues from versions that are no longer shared
726 727 # after +project+ was moved
727 728 def self.update_versions_from_hierarchy_change(project)
728 729 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
729 730 # Update issues of the moved projects and issues assigned to a version of a moved project
730 731 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
731 732 end
732 733
733 734 def parent_issue_id=(arg)
734 735 parent_issue_id = arg.blank? ? nil : arg.to_i
735 736 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
736 737 @parent_issue.id
737 738 else
738 739 @parent_issue = nil
739 740 nil
740 741 end
741 742 end
742 743
743 744 def parent_issue_id
744 745 if instance_variable_defined? :@parent_issue
745 746 @parent_issue.nil? ? nil : @parent_issue.id
746 747 else
747 748 parent_id
748 749 end
749 750 end
750 751
751 752 # Extracted from the ReportsController.
752 753 def self.by_tracker(project)
753 754 count_and_group_by(:project => project,
754 755 :field => 'tracker_id',
755 756 :joins => Tracker.table_name)
756 757 end
757 758
758 759 def self.by_version(project)
759 760 count_and_group_by(:project => project,
760 761 :field => 'fixed_version_id',
761 762 :joins => Version.table_name)
762 763 end
763 764
764 765 def self.by_priority(project)
765 766 count_and_group_by(:project => project,
766 767 :field => 'priority_id',
767 768 :joins => IssuePriority.table_name)
768 769 end
769 770
770 771 def self.by_category(project)
771 772 count_and_group_by(:project => project,
772 773 :field => 'category_id',
773 774 :joins => IssueCategory.table_name)
774 775 end
775 776
776 777 def self.by_assigned_to(project)
777 778 count_and_group_by(:project => project,
778 779 :field => 'assigned_to_id',
779 780 :joins => User.table_name)
780 781 end
781 782
782 783 def self.by_author(project)
783 784 count_and_group_by(:project => project,
784 785 :field => 'author_id',
785 786 :joins => User.table_name)
786 787 end
787 788
788 789 def self.by_subproject(project)
789 790 ActiveRecord::Base.connection.select_all("select s.id as status_id,
790 791 s.is_closed as closed,
791 792 #{Issue.table_name}.project_id as project_id,
792 793 count(#{Issue.table_name}.id) as total
793 794 from
794 795 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
795 796 where
796 797 #{Issue.table_name}.status_id=s.id
797 798 and #{Issue.table_name}.project_id = #{Project.table_name}.id
798 799 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
799 800 and #{Issue.table_name}.project_id <> #{project.id}
800 801 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
801 802 end
802 803 # End ReportsController extraction
803 804
804 805 # Returns an array of projects that user can assign the issue to
805 806 def allowed_target_projects(user=User.current)
806 807 if new_record?
807 808 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
808 809 else
809 810 self.class.allowed_target_projects_on_move(user)
810 811 end
811 812 end
812 813
813 814 # Returns an array of projects that user can move issues to
814 815 def self.allowed_target_projects_on_move(user=User.current)
815 816 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
816 817 end
817 818
818 819 private
819 820
820 821 def after_project_change
821 822 # Update project_id on related time entries
822 823 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
823 824
824 825 # Delete issue relations
825 826 unless Setting.cross_project_issue_relations?
826 827 relations_from.clear
827 828 relations_to.clear
828 829 end
829 830
830 831 # Move subtasks
831 832 children.each do |child|
832 833 # Change project and keep project
833 834 child.send :project=, project, true
834 835 unless child.save
835 836 raise ActiveRecord::Rollback
836 837 end
837 838 end
838 839 end
839 840
840 841 def update_nested_set_attributes
841 842 if root_id.nil?
842 843 # issue was just created
843 844 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
844 845 set_default_left_and_right
845 846 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
846 847 if @parent_issue
847 848 move_to_child_of(@parent_issue)
848 849 end
849 850 reload
850 851 elsif parent_issue_id != parent_id
851 852 former_parent_id = parent_id
852 853 # moving an existing issue
853 854 if @parent_issue && @parent_issue.root_id == root_id
854 855 # inside the same tree
855 856 move_to_child_of(@parent_issue)
856 857 else
857 858 # to another tree
858 859 unless root?
859 860 move_to_right_of(root)
860 861 reload
861 862 end
862 863 old_root_id = root_id
863 864 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
864 865 target_maxright = nested_set_scope.maximum(right_column_name) || 0
865 866 offset = target_maxright + 1 - lft
866 867 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
867 868 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
868 869 self[left_column_name] = lft + offset
869 870 self[right_column_name] = rgt + offset
870 871 if @parent_issue
871 872 move_to_child_of(@parent_issue)
872 873 end
873 874 end
874 875 reload
875 876 # delete invalid relations of all descendants
876 877 self_and_descendants.each do |issue|
877 878 issue.relations.each do |relation|
878 879 relation.destroy unless relation.valid?
879 880 end
880 881 end
881 882 # update former parent
882 883 recalculate_attributes_for(former_parent_id) if former_parent_id
883 884 end
884 885 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
885 886 end
886 887
887 888 def update_parent_attributes
888 889 recalculate_attributes_for(parent_id) if parent_id
889 890 end
890 891
891 892 def recalculate_attributes_for(issue_id)
892 893 if issue_id && p = Issue.find_by_id(issue_id)
893 894 # priority = highest priority of children
894 895 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
895 896 p.priority = IssuePriority.find_by_position(priority_position)
896 897 end
897 898
898 899 # start/due dates = lowest/highest dates of children
899 900 p.start_date = p.children.minimum(:start_date)
900 901 p.due_date = p.children.maximum(:due_date)
901 902 if p.start_date && p.due_date && p.due_date < p.start_date
902 903 p.start_date, p.due_date = p.due_date, p.start_date
903 904 end
904 905
905 906 # done ratio = weighted average ratio of leaves
906 907 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
907 908 leaves_count = p.leaves.count
908 909 if leaves_count > 0
909 910 average = p.leaves.average(:estimated_hours).to_f
910 911 if average == 0
911 912 average = 1
912 913 end
913 914 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
914 915 progress = done / (average * leaves_count)
915 916 p.done_ratio = progress.round
916 917 end
917 918 end
918 919
919 920 # estimate = sum of leaves estimates
920 921 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
921 922 p.estimated_hours = nil if p.estimated_hours == 0.0
922 923
923 924 # ancestors will be recursively updated
924 925 p.save(:validate => false)
925 926 end
926 927 end
927 928
928 929 # Update issues so their versions are not pointing to a
929 930 # fixed_version that is not shared with the issue's project
930 931 def self.update_versions(conditions=nil)
931 932 # Only need to update issues with a fixed_version from
932 933 # a different project and that is not systemwide shared
933 934 Issue.scoped(:conditions => conditions).all(
934 935 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
935 936 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
936 937 " AND #{Version.table_name}.sharing <> 'system'",
937 938 :include => [:project, :fixed_version]
938 939 ).each do |issue|
939 940 next if issue.project.nil? || issue.fixed_version.nil?
940 941 unless issue.project.shared_versions.include?(issue.fixed_version)
941 942 issue.init_journal(User.current)
942 943 issue.fixed_version = nil
943 944 issue.save
944 945 end
945 946 end
946 947 end
947 948
948 949 # Callback on attachment deletion
949 950 def attachment_added(obj)
950 951 if @current_journal && !obj.new_record?
951 952 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
952 953 end
953 954 end
954 955
955 956 # Callback on attachment deletion
956 957 def attachment_removed(obj)
957 958 if @current_journal && !obj.new_record?
958 959 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
959 960 @current_journal.save
960 961 end
961 962 end
962 963
963 964 # Default assignment based on category
964 965 def default_assign
965 966 if assigned_to.nil? && category && category.assigned_to
966 967 self.assigned_to = category.assigned_to
967 968 end
968 969 end
969 970
970 971 # Updates start/due dates of following issues
971 972 def reschedule_following_issues
972 973 if start_date_changed? || due_date_changed?
973 974 relations_from.each do |relation|
974 975 relation.set_issue_to_dates
975 976 end
976 977 end
977 978 end
978 979
979 980 # Closes duplicates if the issue is being closed
980 981 def close_duplicates
981 982 if closing?
982 983 duplicates.each do |duplicate|
983 984 # Reload is need in case the duplicate was updated by a previous duplicate
984 985 duplicate.reload
985 986 # Don't re-close it if it's already closed
986 987 next if duplicate.closed?
987 988 # Same user and notes
988 989 if @current_journal
989 990 duplicate.init_journal(@current_journal.user, @current_journal.notes)
990 991 end
991 992 duplicate.update_attribute :status, self.status
992 993 end
993 994 end
994 995 end
995 996
996 997 # Saves the changes in a Journal
997 998 # Called after_save
998 999 def create_journal
999 1000 if @current_journal
1000 1001 # attributes changes
1001 1002 if @attributes_before_change
1002 1003 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1003 1004 before = @attributes_before_change[c]
1004 1005 after = send(c)
1005 1006 next if before == after || (before.blank? && after.blank?)
1006 1007 @current_journal.details << JournalDetail.new(:property => 'attr',
1007 1008 :prop_key => c,
1008 1009 :old_value => before,
1009 1010 :value => after)
1010 1011 }
1011 1012 end
1012 1013 if @custom_values_before_change
1013 1014 # custom fields changes
1014 1015 custom_field_values.each {|c|
1015 1016 before = @custom_values_before_change[c.custom_field_id]
1016 1017 after = c.value
1017 1018 next if before == after || (before.blank? && after.blank?)
1018 1019
1019 1020 if before.is_a?(Array) || after.is_a?(Array)
1020 1021 before = [before] unless before.is_a?(Array)
1021 1022 after = [after] unless after.is_a?(Array)
1022 1023
1023 1024 # values removed
1024 1025 (before - after).reject(&:blank?).each do |value|
1025 1026 @current_journal.details << JournalDetail.new(:property => 'cf',
1026 1027 :prop_key => c.custom_field_id,
1027 1028 :old_value => value,
1028 1029 :value => nil)
1029 1030 end
1030 1031 # values added
1031 1032 (after - before).reject(&:blank?).each do |value|
1032 1033 @current_journal.details << JournalDetail.new(:property => 'cf',
1033 1034 :prop_key => c.custom_field_id,
1034 1035 :old_value => nil,
1035 1036 :value => value)
1036 1037 end
1037 1038 else
1038 1039 @current_journal.details << JournalDetail.new(:property => 'cf',
1039 1040 :prop_key => c.custom_field_id,
1040 1041 :old_value => before,
1041 1042 :value => after)
1042 1043 end
1043 1044 }
1044 1045 end
1045 1046 @current_journal.save
1046 1047 # reset current journal
1047 1048 init_journal @current_journal.user, @current_journal.notes
1048 1049 end
1049 1050 end
1050 1051
1051 1052 # Query generator for selecting groups of issue counts for a project
1052 1053 # based on specific criteria
1053 1054 #
1054 1055 # Options
1055 1056 # * project - Project to search in.
1056 1057 # * field - String. Issue field to key off of in the grouping.
1057 1058 # * joins - String. The table name to join against.
1058 1059 def self.count_and_group_by(options)
1059 1060 project = options.delete(:project)
1060 1061 select_field = options.delete(:field)
1061 1062 joins = options.delete(:joins)
1062 1063
1063 1064 where = "#{Issue.table_name}.#{select_field}=j.id"
1064 1065
1065 1066 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1066 1067 s.is_closed as closed,
1067 1068 j.id as #{select_field},
1068 1069 count(#{Issue.table_name}.id) as total
1069 1070 from
1070 1071 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1071 1072 where
1072 1073 #{Issue.table_name}.status_id=s.id
1073 1074 and #{where}
1074 1075 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1075 1076 and #{visible_condition(User.current, :project => project)}
1076 1077 group by s.id, s.is_closed, j.id")
1077 1078 end
1078 1079 end
@@ -1,48 +1,48
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueCategory < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 belongs_to :project
21 21 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
22 22 has_many :issues, :foreign_key => 'category_id', :dependent => :nullify
23 23
24 24 validates_presence_of :name
25 25 validates_uniqueness_of :name, :scope => [:project_id]
26 26 validates_length_of :name, :maximum => 30
27 27
28 28 safe_attributes 'name', 'assigned_to_id'
29 29
30 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
30 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
31 31
32 32 alias :destroy_without_reassign :destroy
33 33
34 34 # Destroy the category
35 35 # If a category is specified, issues are reassigned to this category
36 36 def destroy(reassign_to = nil)
37 37 if reassign_to && reassign_to.is_a?(IssueCategory) && reassign_to.project == self.project
38 38 Issue.update_all("category_id = #{reassign_to.id}", "category_id = #{id}")
39 39 end
40 40 destroy_without_reassign
41 41 end
42 42
43 43 def <=>(category)
44 44 name <=> category.name
45 45 end
46 46
47 47 def to_s; name end
48 48 end
@@ -1,104 +1,104
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueStatus < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 20 has_many :workflows, :foreign_key => "old_status_id"
21 21 acts_as_list
22 22
23 23 before_destroy :delete_workflows
24 24 after_save :update_default
25 25
26 26 validates_presence_of :name
27 27 validates_uniqueness_of :name
28 28 validates_length_of :name, :maximum => 30
29 29 validates_inclusion_of :default_done_ratio, :in => 0..100, :allow_nil => true
30 30
31 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
31 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
32 32
33 33 def update_default
34 34 IssueStatus.update_all("is_default=#{connection.quoted_false}", ['id <> ?', id]) if self.is_default?
35 35 end
36 36
37 37 # Returns the default status for new issues
38 38 def self.default
39 39 find(:first, :conditions =>["is_default=?", true])
40 40 end
41 41
42 42 # Update all the +Issues+ setting their done_ratio to the value of their +IssueStatus+
43 43 def self.update_issue_done_ratios
44 44 if Issue.use_status_for_done_ratio?
45 45 IssueStatus.find(:all, :conditions => ["default_done_ratio >= 0"]).each do |status|
46 46 Issue.update_all(["done_ratio = ?", status.default_done_ratio],
47 47 ["status_id = ?", status.id])
48 48 end
49 49 end
50 50
51 51 return Issue.use_status_for_done_ratio?
52 52 end
53 53
54 54 # Returns an array of all statuses the given role can switch to
55 55 # Uses association cache when called more than one time
56 56 def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
57 57 if roles && tracker
58 58 role_ids = roles.collect(&:id)
59 59 transitions = workflows.select do |w|
60 60 role_ids.include?(w.role_id) &&
61 61 w.tracker_id == tracker.id &&
62 62 ((!w.author && !w.assignee) || (author && w.author) || (assignee && w.assignee))
63 63 end
64 64 transitions.collect{|w| w.new_status}.compact.sort
65 65 else
66 66 []
67 67 end
68 68 end
69 69
70 70 # Same thing as above but uses a database query
71 71 # More efficient than the previous method if called just once
72 72 def find_new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
73 73 if roles.present? && tracker
74 74 conditions = "(author = :false AND assignee = :false)"
75 75 conditions << " OR author = :true" if author
76 76 conditions << " OR assignee = :true" if assignee
77 77
78 78 workflows.find(:all,
79 79 :include => :new_status,
80 80 :conditions => ["role_id IN (:role_ids) AND tracker_id = :tracker_id AND (#{conditions})",
81 81 {:role_ids => roles.collect(&:id), :tracker_id => tracker.id, :true => true, :false => false}
82 82 ]
83 83 ).collect{|w| w.new_status}.compact.sort
84 84 else
85 85 []
86 86 end
87 87 end
88 88
89 89 def <=>(status)
90 90 position <=> status.position
91 91 end
92 92
93 93 def to_s; name end
94 94
95 95 private
96 96 def check_integrity
97 97 raise "Can't delete status" if Issue.find(:first, :conditions => ["status_id=?", self.id])
98 98 end
99 99
100 100 # Deletes associated workflows
101 101 def delete_workflows
102 102 Workflow.delete_all(["old_status_id = :id OR new_status_id = :id", {:id => id}])
103 103 end
104 104 end
@@ -1,88 +1,88
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Journal < ActiveRecord::Base
19 19 belongs_to :journalized, :polymorphic => true
20 20 # added as a quick fix to allow eager loading of the polymorphic association
21 21 # since always associated to an issue, for now
22 22 belongs_to :issue, :foreign_key => :journalized_id
23 23
24 24 belongs_to :user
25 25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
26 26 attr_accessor :indice
27 27
28 28 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
29 29 :description => :notes,
30 30 :author => :user,
31 31 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
32 32 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
33 33
34 34 acts_as_activity_provider :type => 'issues',
35 35 :author_key => :user_id,
36 36 :find_options => {:include => [{:issue => :project}, :details, :user],
37 37 :conditions => "#{Journal.table_name}.journalized_type = 'Issue' AND" +
38 38 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')"}
39 39
40 named_scope :visible, lambda {|*args| {
40 scope :visible, lambda {|*args| {
41 41 :include => {:issue => :project},
42 42 :conditions => Issue.visible_condition(args.shift || User.current, *args)
43 43 }}
44 44
45 45 def save(*args)
46 46 # Do not save an empty journal
47 47 (details.empty? && notes.blank?) ? false : super
48 48 end
49 49
50 50 # Returns the new status if the journal contains a status change, otherwise nil
51 51 def new_status
52 52 c = details.detect {|detail| detail.prop_key == 'status_id'}
53 53 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
54 54 end
55 55
56 56 def new_value_for(prop)
57 57 c = details.detect {|detail| detail.prop_key == prop}
58 58 c ? c.value : nil
59 59 end
60 60
61 61 def editable_by?(usr)
62 62 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
63 63 end
64 64
65 65 def project
66 66 journalized.respond_to?(:project) ? journalized.project : nil
67 67 end
68 68
69 69 def attachments
70 70 journalized.respond_to?(:attachments) ? journalized.attachments : nil
71 71 end
72 72
73 73 # Returns a string of css classes
74 74 def css_classes
75 75 s = 'journal'
76 76 s << ' has-notes' unless notes.blank?
77 77 s << ' has-details' unless details.blank?
78 78 s
79 79 end
80 80
81 81 def notify?
82 82 @notify != false
83 83 end
84 84
85 85 def notify=(arg)
86 86 @notify = arg
87 87 end
88 88 end
@@ -1,110 +1,110
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Message < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 belongs_to :board
21 21 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 22 acts_as_tree :counter_cache => :replies_count, :order => "#{Message.table_name}.created_on ASC"
23 23 acts_as_attachable
24 24 belongs_to :last_reply, :class_name => 'Message', :foreign_key => 'last_reply_id'
25 25
26 26 acts_as_searchable :columns => ['subject', 'content'],
27 27 :include => {:board => :project},
28 28 :project_key => "#{Board.table_name}.project_id",
29 29 :date_column => "#{table_name}.created_on"
30 30 acts_as_event :title => Proc.new {|o| "#{o.board.name}: #{o.subject}"},
31 31 :description => :content,
32 32 :type => Proc.new {|o| o.parent_id.nil? ? 'message' : 'reply'},
33 33 :url => Proc.new {|o| {:controller => 'messages', :action => 'show', :board_id => o.board_id}.merge(o.parent_id.nil? ? {:id => o.id} :
34 34 {:id => o.parent_id, :r => o.id, :anchor => "message-#{o.id}"})}
35 35
36 36 acts_as_activity_provider :find_options => {:include => [{:board => :project}, :author]},
37 37 :author_key => :author_id
38 38 acts_as_watchable
39 39
40 40 validates_presence_of :board, :subject, :content
41 41 validates_length_of :subject, :maximum => 255
42 42 validate :cannot_reply_to_locked_topic, :on => :create
43 43
44 44 after_create :add_author_as_watcher, :update_parent_last_reply
45 45 after_update :update_messages_board
46 46 after_destroy :reset_board_counters
47 47
48 named_scope :visible, lambda {|*args| { :include => {:board => :project},
48 scope :visible, lambda {|*args| { :include => {:board => :project},
49 49 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
50 50
51 51 safe_attributes 'subject', 'content'
52 52 safe_attributes 'locked', 'sticky', 'board_id',
53 53 :if => lambda {|message, user|
54 54 user.allowed_to?(:edit_messages, message.project)
55 55 }
56 56
57 57 def visible?(user=User.current)
58 58 !user.nil? && user.allowed_to?(:view_messages, project)
59 59 end
60 60
61 61 def cannot_reply_to_locked_topic
62 62 # Can not reply to a locked topic
63 63 errors.add :base, 'Topic is locked' if root.locked? && self != root
64 64 end
65 65
66 66 def update_parent_last_reply
67 67 if parent
68 68 parent.reload.update_attribute(:last_reply_id, self.id)
69 69 end
70 70 board.reset_counters!
71 71 end
72 72
73 73 def update_messages_board
74 74 if board_id_changed?
75 75 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
76 76 Board.reset_counters!(board_id_was)
77 77 Board.reset_counters!(board_id)
78 78 end
79 79 end
80 80
81 81 def reset_board_counters
82 82 board.reset_counters!
83 83 end
84 84
85 85 def sticky=(arg)
86 86 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
87 87 end
88 88
89 89 def sticky?
90 90 sticky == 1
91 91 end
92 92
93 93 def project
94 94 board.project
95 95 end
96 96
97 97 def editable_by?(usr)
98 98 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
99 99 end
100 100
101 101 def destroyable_by?(usr)
102 102 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
103 103 end
104 104
105 105 private
106 106
107 107 def add_author_as_watcher
108 108 Watcher.create(:watchable => self.root, :user => author)
109 109 end
110 110 end
@@ -1,66 +1,66
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class News < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 belongs_to :project
21 21 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 22 has_many :comments, :as => :commented, :dependent => :delete_all, :order => "created_on"
23 23
24 24 validates_presence_of :title, :description
25 25 validates_length_of :title, :maximum => 60
26 26 validates_length_of :summary, :maximum => 255
27 27
28 28 acts_as_attachable :delete_permission => :manage_news
29 29 acts_as_searchable :columns => ['title', 'summary', "#{table_name}.description"], :include => :project
30 30 acts_as_event :url => Proc.new {|o| {:controller => 'news', :action => 'show', :id => o.id}}
31 31 acts_as_activity_provider :find_options => {:include => [:project, :author]},
32 32 :author_key => :author_id
33 33 acts_as_watchable
34 34
35 35 after_create :add_author_as_watcher
36 36
37 named_scope :visible, lambda {|*args| {
37 scope :visible, lambda {|*args| {
38 38 :include => :project,
39 39 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_news, *args)
40 40 }}
41 41
42 42 safe_attributes 'title', 'summary', 'description'
43 43
44 44 def visible?(user=User.current)
45 45 !user.nil? && user.allowed_to?(:view_news, project)
46 46 end
47 47
48 48 # Returns true if the news can be commented by user
49 49 def commentable?(user=User.current)
50 50 user.allowed_to?(:comment_news, project)
51 51 end
52 52
53 53 # returns latest news for projects visible by user
54 54 def self.latest(user = User.current, count = 5)
55 55 find(:all, :limit => count,
56 56 :conditions => Project.allowed_to_condition(user, :view_news),
57 57 :include => [ :author, :project ],
58 58 :order => "#{News.table_name}.created_on DESC")
59 59 end
60 60
61 61 private
62 62
63 63 def add_author_as_watcher
64 64 Watcher.create(:watchable => self, :user => author)
65 65 end
66 66 end
@@ -1,95 +1,95
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Principal < ActiveRecord::Base
19 19 set_table_name "#{table_name_prefix}users#{table_name_suffix}"
20 20
21 21 has_many :members, :foreign_key => 'user_id', :dependent => :destroy
22 22 has_many :memberships, :class_name => 'Member', :foreign_key => 'user_id', :include => [ :project, :roles ], :conditions => "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}", :order => "#{Project.table_name}.name"
23 23 has_many :projects, :through => :memberships
24 24 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
25 25
26 26 # Groups and active users
27 named_scope :active, :conditions => "#{Principal.table_name}.status = 1"
27 scope :active, :conditions => "#{Principal.table_name}.status = 1"
28 28
29 named_scope :like, lambda {|q|
29 scope :like, lambda {|q|
30 30 if q.blank?
31 31 {}
32 32 else
33 33 q = q.to_s.downcase
34 34 pattern = "%#{q}%"
35 35 sql = "LOWER(login) LIKE :p OR LOWER(firstname) LIKE :p OR LOWER(lastname) LIKE :p OR LOWER(mail) LIKE :p"
36 36 params = {:p => pattern}
37 37 if q =~ /^(.+)\s+(.+)$/
38 38 a, b = "#{$1}%", "#{$2}%"
39 39 sql << " OR (LOWER(firstname) LIKE :a AND LOWER(lastname) LIKE :b) OR (LOWER(firstname) LIKE :b AND LOWER(lastname) LIKE :a)"
40 40 params.merge!(:a => a, :b => b)
41 41 end
42 42 {:conditions => [sql, params]}
43 43 end
44 44 }
45 45
46 46 # Principals that are members of a collection of projects
47 named_scope :member_of, lambda {|projects|
47 scope :member_of, lambda {|projects|
48 48 projects = [projects] unless projects.is_a?(Array)
49 49 if projects.empty?
50 50 {:conditions => "1=0"}
51 51 else
52 52 ids = projects.map(&:id)
53 53 {:conditions => ["#{Principal.table_name}.status = 1 AND #{Principal.table_name}.id IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids]}
54 54 end
55 55 }
56 56 # Principals that are not members of projects
57 named_scope :not_member_of, lambda {|projects|
57 scope :not_member_of, lambda {|projects|
58 58 projects = [projects] unless projects.is_a?(Array)
59 59 if projects.empty?
60 60 {:conditions => "1=0"}
61 61 else
62 62 ids = projects.map(&:id)
63 63 {:conditions => ["#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids]}
64 64 end
65 65 }
66 66
67 67 before_create :set_default_empty_values
68 68
69 69 def name(formatter = nil)
70 70 to_s
71 71 end
72 72
73 73 def <=>(principal)
74 74 if principal.nil?
75 75 -1
76 76 elsif self.class.name == principal.class.name
77 77 self.to_s.downcase <=> principal.to_s.downcase
78 78 else
79 79 # groups after users
80 80 principal.class.name <=> self.class.name
81 81 end
82 82 end
83 83
84 84 protected
85 85
86 86 # Make sure we don't try to insert NULL values (see #4632)
87 87 def set_default_empty_values
88 88 self.login ||= ''
89 89 self.hashed_password ||= ''
90 90 self.firstname ||= ''
91 91 self.lastname ||= ''
92 92 self.mail ||= ''
93 93 true
94 94 end
95 95 end
@@ -1,922 +1,922
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_ARCHIVED = 9
24 24
25 25 # Maximum length for project identifiers
26 26 IDENTIFIER_MAX_LENGTH = 100
27 27
28 28 # Specific overidden Activities
29 29 has_many :time_entry_activities
30 30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
31 31 has_many :memberships, :class_name => 'Member'
32 32 has_many :member_principals, :class_name => 'Member',
33 33 :include => :principal,
34 34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
35 35 has_many :users, :through => :members
36 36 has_many :principals, :through => :member_principals, :source => :principal
37 37
38 38 has_many :enabled_modules, :dependent => :delete_all
39 39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
40 40 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
41 41 has_many :issue_changes, :through => :issues, :source => :journals
42 42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
43 43 has_many :time_entries, :dependent => :delete_all
44 44 has_many :queries, :dependent => :delete_all
45 45 has_many :documents, :dependent => :destroy
46 46 has_many :news, :dependent => :destroy, :include => :author
47 47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
48 48 has_many :boards, :dependent => :destroy, :order => "position ASC"
49 49 has_one :repository, :conditions => ["is_default = ?", true]
50 50 has_many :repositories, :dependent => :destroy
51 51 has_many :changesets, :through => :repository
52 52 has_one :wiki, :dependent => :destroy
53 53 # Custom field for the project issues
54 54 has_and_belongs_to_many :issue_custom_fields,
55 55 :class_name => 'IssueCustomField',
56 56 :order => "#{CustomField.table_name}.position",
57 57 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
58 58 :association_foreign_key => 'custom_field_id'
59 59
60 60 acts_as_nested_set :order => 'name', :dependent => :destroy
61 61 acts_as_attachable :view_permission => :view_files,
62 62 :delete_permission => :manage_files
63 63
64 64 acts_as_customizable
65 65 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
66 66 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
67 67 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
68 68 :author => nil
69 69
70 70 attr_protected :status
71 71
72 72 validates_presence_of :name, :identifier
73 73 validates_uniqueness_of :identifier
74 74 validates_associated :repository, :wiki
75 75 validates_length_of :name, :maximum => 255
76 76 validates_length_of :homepage, :maximum => 255
77 77 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
78 78 # donwcase letters, digits, dashes but not digits only
79 79 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
80 80 # reserved words
81 81 validates_exclusion_of :identifier, :in => %w( new )
82 82
83 83 before_destroy :delete_all_members
84 84
85 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
86 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
87 named_scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
88 named_scope :all_public, { :conditions => { :is_public => true } }
89 named_scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
90 named_scope :allowed_to, lambda {|*args|
85 scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
86 scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
87 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
88 scope :all_public, { :conditions => { :is_public => true } }
89 scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
90 scope :allowed_to, lambda {|*args|
91 91 user = User.current
92 92 permission = nil
93 93 if args.first.is_a?(Symbol)
94 94 permission = args.shift
95 95 else
96 96 user = args.shift
97 97 permission = args.shift
98 98 end
99 99 { :conditions => Project.allowed_to_condition(user, permission, *args) }
100 100 }
101 named_scope :like, lambda {|arg|
101 scope :like, lambda {|arg|
102 102 if arg.blank?
103 103 {}
104 104 else
105 105 pattern = "%#{arg.to_s.strip.downcase}%"
106 106 {:conditions => ["LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", {:p => pattern}]}
107 107 end
108 108 }
109 109
110 110 def initialize(attributes=nil, *args)
111 111 super
112 112
113 113 initialized = (attributes || {}).stringify_keys
114 114 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
115 115 self.identifier = Project.next_identifier
116 116 end
117 117 if !initialized.key?('is_public')
118 118 self.is_public = Setting.default_projects_public?
119 119 end
120 120 if !initialized.key?('enabled_module_names')
121 121 self.enabled_module_names = Setting.default_projects_modules
122 122 end
123 123 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
124 124 self.trackers = Tracker.all
125 125 end
126 126 end
127 127
128 128 def identifier=(identifier)
129 129 super unless identifier_frozen?
130 130 end
131 131
132 132 def identifier_frozen?
133 133 errors[:identifier].nil? && !(new_record? || identifier.blank?)
134 134 end
135 135
136 136 # returns latest created projects
137 137 # non public projects will be returned only if user is a member of those
138 138 def self.latest(user=nil, count=5)
139 139 visible(user).find(:all, :limit => count, :order => "created_on DESC")
140 140 end
141 141
142 142 # Returns true if the project is visible to +user+ or to the current user.
143 143 def visible?(user=User.current)
144 144 user.allowed_to?(:view_project, self)
145 145 end
146 146
147 147 # Returns a SQL conditions string used to find all projects visible by the specified user.
148 148 #
149 149 # Examples:
150 150 # Project.visible_condition(admin) => "projects.status = 1"
151 151 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
152 152 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
153 153 def self.visible_condition(user, options={})
154 154 allowed_to_condition(user, :view_project, options)
155 155 end
156 156
157 157 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
158 158 #
159 159 # Valid options:
160 160 # * :project => limit the condition to project
161 161 # * :with_subprojects => limit the condition to project and its subprojects
162 162 # * :member => limit the condition to the user projects
163 163 def self.allowed_to_condition(user, permission, options={})
164 164 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
165 165 if perm = Redmine::AccessControl.permission(permission)
166 166 unless perm.project_module.nil?
167 167 # If the permission belongs to a project module, make sure the module is enabled
168 168 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
169 169 end
170 170 end
171 171 if options[:project]
172 172 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
173 173 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
174 174 base_statement = "(#{project_statement}) AND (#{base_statement})"
175 175 end
176 176
177 177 if user.admin?
178 178 base_statement
179 179 else
180 180 statement_by_role = {}
181 181 unless options[:member]
182 182 role = user.logged? ? Role.non_member : Role.anonymous
183 183 if role.allowed_to?(permission)
184 184 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
185 185 end
186 186 end
187 187 if user.logged?
188 188 user.projects_by_role.each do |role, projects|
189 189 if role.allowed_to?(permission)
190 190 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
191 191 end
192 192 end
193 193 end
194 194 if statement_by_role.empty?
195 195 "1=0"
196 196 else
197 197 if block_given?
198 198 statement_by_role.each do |role, statement|
199 199 if s = yield(role, user)
200 200 statement_by_role[role] = "(#{statement} AND (#{s}))"
201 201 end
202 202 end
203 203 end
204 204 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
205 205 end
206 206 end
207 207 end
208 208
209 209 # Returns the Systemwide and project specific activities
210 210 def activities(include_inactive=false)
211 211 if include_inactive
212 212 return all_activities
213 213 else
214 214 return active_activities
215 215 end
216 216 end
217 217
218 218 # Will create a new Project specific Activity or update an existing one
219 219 #
220 220 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
221 221 # does not successfully save.
222 222 def update_or_create_time_entry_activity(id, activity_hash)
223 223 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
224 224 self.create_time_entry_activity_if_needed(activity_hash)
225 225 else
226 226 activity = project.time_entry_activities.find_by_id(id.to_i)
227 227 activity.update_attributes(activity_hash) if activity
228 228 end
229 229 end
230 230
231 231 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
232 232 #
233 233 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
234 234 # does not successfully save.
235 235 def create_time_entry_activity_if_needed(activity)
236 236 if activity['parent_id']
237 237
238 238 parent_activity = TimeEntryActivity.find(activity['parent_id'])
239 239 activity['name'] = parent_activity.name
240 240 activity['position'] = parent_activity.position
241 241
242 242 if Enumeration.overridding_change?(activity, parent_activity)
243 243 project_activity = self.time_entry_activities.create(activity)
244 244
245 245 if project_activity.new_record?
246 246 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
247 247 else
248 248 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
249 249 end
250 250 end
251 251 end
252 252 end
253 253
254 254 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
255 255 #
256 256 # Examples:
257 257 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
258 258 # project.project_condition(false) => "projects.id = 1"
259 259 def project_condition(with_subprojects)
260 260 cond = "#{Project.table_name}.id = #{id}"
261 261 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
262 262 cond
263 263 end
264 264
265 265 def self.find(*args)
266 266 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
267 267 project = find_by_identifier(*args)
268 268 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
269 269 project
270 270 else
271 271 super
272 272 end
273 273 end
274 274
275 275 def self.find_by_param(*args)
276 276 self.find(*args)
277 277 end
278 278
279 279 def reload(*args)
280 280 @shared_versions = nil
281 281 @rolled_up_versions = nil
282 282 @rolled_up_trackers = nil
283 283 @all_issue_custom_fields = nil
284 284 @all_time_entry_custom_fields = nil
285 285 @to_param = nil
286 286 @allowed_parents = nil
287 287 @allowed_permissions = nil
288 288 @actions_allowed = nil
289 289 super
290 290 end
291 291
292 292 def to_param
293 293 # id is used for projects with a numeric identifier (compatibility)
294 294 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
295 295 end
296 296
297 297 def active?
298 298 self.status == STATUS_ACTIVE
299 299 end
300 300
301 301 def archived?
302 302 self.status == STATUS_ARCHIVED
303 303 end
304 304
305 305 # Archives the project and its descendants
306 306 def archive
307 307 # Check that there is no issue of a non descendant project that is assigned
308 308 # to one of the project or descendant versions
309 309 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
310 310 if v_ids.any? && Issue.find(:first, :include => :project,
311 311 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
312 312 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
313 313 return false
314 314 end
315 315 Project.transaction do
316 316 archive!
317 317 end
318 318 true
319 319 end
320 320
321 321 # Unarchives the project
322 322 # All its ancestors must be active
323 323 def unarchive
324 324 return false if ancestors.detect {|a| !a.active?}
325 325 update_attribute :status, STATUS_ACTIVE
326 326 end
327 327
328 328 # Returns an array of projects the project can be moved to
329 329 # by the current user
330 330 def allowed_parents
331 331 return @allowed_parents if @allowed_parents
332 332 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
333 333 @allowed_parents = @allowed_parents - self_and_descendants
334 334 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
335 335 @allowed_parents << nil
336 336 end
337 337 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
338 338 @allowed_parents << parent
339 339 end
340 340 @allowed_parents
341 341 end
342 342
343 343 # Sets the parent of the project with authorization check
344 344 def set_allowed_parent!(p)
345 345 unless p.nil? || p.is_a?(Project)
346 346 if p.to_s.blank?
347 347 p = nil
348 348 else
349 349 p = Project.find_by_id(p)
350 350 return false unless p
351 351 end
352 352 end
353 353 if p.nil?
354 354 if !new_record? && allowed_parents.empty?
355 355 return false
356 356 end
357 357 elsif !allowed_parents.include?(p)
358 358 return false
359 359 end
360 360 set_parent!(p)
361 361 end
362 362
363 363 # Sets the parent of the project
364 364 # Argument can be either a Project, a String, a Fixnum or nil
365 365 def set_parent!(p)
366 366 unless p.nil? || p.is_a?(Project)
367 367 if p.to_s.blank?
368 368 p = nil
369 369 else
370 370 p = Project.find_by_id(p)
371 371 return false unless p
372 372 end
373 373 end
374 374 if p == parent && !p.nil?
375 375 # Nothing to do
376 376 true
377 377 elsif p.nil? || (p.active? && move_possible?(p))
378 378 # Insert the project so that target's children or root projects stay alphabetically sorted
379 379 sibs = (p.nil? ? self.class.roots : p.children)
380 380 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
381 381 if to_be_inserted_before
382 382 move_to_left_of(to_be_inserted_before)
383 383 elsif p.nil?
384 384 if sibs.empty?
385 385 # move_to_root adds the project in first (ie. left) position
386 386 move_to_root
387 387 else
388 388 move_to_right_of(sibs.last) unless self == sibs.last
389 389 end
390 390 else
391 391 # move_to_child_of adds the project in last (ie.right) position
392 392 move_to_child_of(p)
393 393 end
394 394 Issue.update_versions_from_hierarchy_change(self)
395 395 true
396 396 else
397 397 # Can not move to the given target
398 398 false
399 399 end
400 400 end
401 401
402 402 # Returns an array of the trackers used by the project and its active sub projects
403 403 def rolled_up_trackers
404 404 @rolled_up_trackers ||=
405 405 Tracker.find(:all, :joins => :projects,
406 406 :select => "DISTINCT #{Tracker.table_name}.*",
407 407 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
408 408 :order => "#{Tracker.table_name}.position")
409 409 end
410 410
411 411 # Closes open and locked project versions that are completed
412 412 def close_completed_versions
413 413 Version.transaction do
414 414 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
415 415 if version.completed?
416 416 version.update_attribute(:status, 'closed')
417 417 end
418 418 end
419 419 end
420 420 end
421 421
422 422 # Returns a scope of the Versions on subprojects
423 423 def rolled_up_versions
424 424 @rolled_up_versions ||=
425 425 Version.scoped(:include => :project,
426 426 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
427 427 end
428 428
429 429 # Returns a scope of the Versions used by the project
430 430 def shared_versions
431 431 if new_record?
432 432 Version.scoped(:include => :project,
433 433 :conditions => "#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND #{Version.table_name}.sharing = 'system'")
434 434 else
435 435 @shared_versions ||= begin
436 436 r = root? ? self : root
437 437 Version.scoped(:include => :project,
438 438 :conditions => "#{Project.table_name}.id = #{id}" +
439 439 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
440 440 " #{Version.table_name}.sharing = 'system'" +
441 441 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
442 442 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
443 443 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
444 444 "))")
445 445 end
446 446 end
447 447 end
448 448
449 449 # Returns a hash of project users grouped by role
450 450 def users_by_role
451 451 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
452 452 m.roles.each do |r|
453 453 h[r] ||= []
454 454 h[r] << m.user
455 455 end
456 456 h
457 457 end
458 458 end
459 459
460 460 # Deletes all project's members
461 461 def delete_all_members
462 462 me, mr = Member.table_name, MemberRole.table_name
463 463 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
464 464 Member.delete_all(['project_id = ?', id])
465 465 end
466 466
467 467 # Users/groups issues can be assigned to
468 468 def assignable_users
469 469 assignable = Setting.issue_group_assignment? ? member_principals : members
470 470 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
471 471 end
472 472
473 473 # Returns the mail adresses of users that should be always notified on project events
474 474 def recipients
475 475 notified_users.collect {|user| user.mail}
476 476 end
477 477
478 478 # Returns the users that should be notified on project events
479 479 def notified_users
480 480 # TODO: User part should be extracted to User#notify_about?
481 481 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
482 482 end
483 483
484 484 # Returns an array of all custom fields enabled for project issues
485 485 # (explictly associated custom fields and custom fields enabled for all projects)
486 486 def all_issue_custom_fields
487 487 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
488 488 end
489 489
490 490 # Returns an array of all custom fields enabled for project time entries
491 491 # (explictly associated custom fields and custom fields enabled for all projects)
492 492 def all_time_entry_custom_fields
493 493 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
494 494 end
495 495
496 496 def project
497 497 self
498 498 end
499 499
500 500 def <=>(project)
501 501 name.downcase <=> project.name.downcase
502 502 end
503 503
504 504 def to_s
505 505 name
506 506 end
507 507
508 508 # Returns a short description of the projects (first lines)
509 509 def short_description(length = 255)
510 510 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
511 511 end
512 512
513 513 def css_classes
514 514 s = 'project'
515 515 s << ' root' if root?
516 516 s << ' child' if child?
517 517 s << (leaf? ? ' leaf' : ' parent')
518 518 s
519 519 end
520 520
521 521 # The earliest start date of a project, based on it's issues and versions
522 522 def start_date
523 523 [
524 524 issues.minimum('start_date'),
525 525 shared_versions.collect(&:effective_date),
526 526 shared_versions.collect(&:start_date)
527 527 ].flatten.compact.min
528 528 end
529 529
530 530 # The latest due date of an issue or version
531 531 def due_date
532 532 [
533 533 issues.maximum('due_date'),
534 534 shared_versions.collect(&:effective_date),
535 535 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
536 536 ].flatten.compact.max
537 537 end
538 538
539 539 def overdue?
540 540 active? && !due_date.nil? && (due_date < Date.today)
541 541 end
542 542
543 543 # Returns the percent completed for this project, based on the
544 544 # progress on it's versions.
545 545 def completed_percent(options={:include_subprojects => false})
546 546 if options.delete(:include_subprojects)
547 547 total = self_and_descendants.collect(&:completed_percent).sum
548 548
549 549 total / self_and_descendants.count
550 550 else
551 551 if versions.count > 0
552 552 total = versions.collect(&:completed_pourcent).sum
553 553
554 554 total / versions.count
555 555 else
556 556 100
557 557 end
558 558 end
559 559 end
560 560
561 561 # Return true if this project is allowed to do the specified action.
562 562 # action can be:
563 563 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
564 564 # * a permission Symbol (eg. :edit_project)
565 565 def allows_to?(action)
566 566 if action.is_a? Hash
567 567 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
568 568 else
569 569 allowed_permissions.include? action
570 570 end
571 571 end
572 572
573 573 def module_enabled?(module_name)
574 574 module_name = module_name.to_s
575 575 enabled_modules.detect {|m| m.name == module_name}
576 576 end
577 577
578 578 def enabled_module_names=(module_names)
579 579 if module_names && module_names.is_a?(Array)
580 580 module_names = module_names.collect(&:to_s).reject(&:blank?)
581 581 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
582 582 else
583 583 enabled_modules.clear
584 584 end
585 585 end
586 586
587 587 # Returns an array of the enabled modules names
588 588 def enabled_module_names
589 589 enabled_modules.collect(&:name)
590 590 end
591 591
592 592 # Enable a specific module
593 593 #
594 594 # Examples:
595 595 # project.enable_module!(:issue_tracking)
596 596 # project.enable_module!("issue_tracking")
597 597 def enable_module!(name)
598 598 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
599 599 end
600 600
601 601 # Disable a module if it exists
602 602 #
603 603 # Examples:
604 604 # project.disable_module!(:issue_tracking)
605 605 # project.disable_module!("issue_tracking")
606 606 # project.disable_module!(project.enabled_modules.first)
607 607 def disable_module!(target)
608 608 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
609 609 target.destroy unless target.blank?
610 610 end
611 611
612 612 safe_attributes 'name',
613 613 'description',
614 614 'homepage',
615 615 'is_public',
616 616 'identifier',
617 617 'custom_field_values',
618 618 'custom_fields',
619 619 'tracker_ids',
620 620 'issue_custom_field_ids'
621 621
622 622 safe_attributes 'enabled_module_names',
623 623 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
624 624
625 625 # Returns an array of projects that are in this project's hierarchy
626 626 #
627 627 # Example: parents, children, siblings
628 628 def hierarchy
629 629 parents = project.self_and_ancestors || []
630 630 descendants = project.descendants || []
631 631 project_hierarchy = parents | descendants # Set union
632 632 end
633 633
634 634 # Returns an auto-generated project identifier based on the last identifier used
635 635 def self.next_identifier
636 636 p = Project.find(:first, :order => 'created_on DESC')
637 637 p.nil? ? nil : p.identifier.to_s.succ
638 638 end
639 639
640 640 # Copies and saves the Project instance based on the +project+.
641 641 # Duplicates the source project's:
642 642 # * Wiki
643 643 # * Versions
644 644 # * Categories
645 645 # * Issues
646 646 # * Members
647 647 # * Queries
648 648 #
649 649 # Accepts an +options+ argument to specify what to copy
650 650 #
651 651 # Examples:
652 652 # project.copy(1) # => copies everything
653 653 # project.copy(1, :only => 'members') # => copies members only
654 654 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
655 655 def copy(project, options={})
656 656 project = project.is_a?(Project) ? project : Project.find(project)
657 657
658 658 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
659 659 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
660 660
661 661 Project.transaction do
662 662 if save
663 663 reload
664 664 to_be_copied.each do |name|
665 665 send "copy_#{name}", project
666 666 end
667 667 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
668 668 save
669 669 end
670 670 end
671 671 end
672 672
673 673
674 674 # Copies +project+ and returns the new instance. This will not save
675 675 # the copy
676 676 def self.copy_from(project)
677 677 begin
678 678 project = project.is_a?(Project) ? project : Project.find(project)
679 679 if project
680 680 # clear unique attributes
681 681 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
682 682 copy = Project.new(attributes)
683 683 copy.enabled_modules = project.enabled_modules
684 684 copy.trackers = project.trackers
685 685 copy.custom_values = project.custom_values.collect {|v| v.clone}
686 686 copy.issue_custom_fields = project.issue_custom_fields
687 687 return copy
688 688 else
689 689 return nil
690 690 end
691 691 rescue ActiveRecord::RecordNotFound
692 692 return nil
693 693 end
694 694 end
695 695
696 696 # Yields the given block for each project with its level in the tree
697 697 def self.project_tree(projects, &block)
698 698 ancestors = []
699 699 projects.sort_by(&:lft).each do |project|
700 700 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
701 701 ancestors.pop
702 702 end
703 703 yield project, ancestors.size
704 704 ancestors << project
705 705 end
706 706 end
707 707
708 708 private
709 709
710 710 # Copies wiki from +project+
711 711 def copy_wiki(project)
712 712 # Check that the source project has a wiki first
713 713 unless project.wiki.nil?
714 714 self.wiki ||= Wiki.new
715 715 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
716 716 wiki_pages_map = {}
717 717 project.wiki.pages.each do |page|
718 718 # Skip pages without content
719 719 next if page.content.nil?
720 720 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
721 721 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
722 722 new_wiki_page.content = new_wiki_content
723 723 wiki.pages << new_wiki_page
724 724 wiki_pages_map[page.id] = new_wiki_page
725 725 end
726 726 wiki.save
727 727 # Reproduce page hierarchy
728 728 project.wiki.pages.each do |page|
729 729 if page.parent_id && wiki_pages_map[page.id]
730 730 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
731 731 wiki_pages_map[page.id].save
732 732 end
733 733 end
734 734 end
735 735 end
736 736
737 737 # Copies versions from +project+
738 738 def copy_versions(project)
739 739 project.versions.each do |version|
740 740 new_version = Version.new
741 741 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
742 742 self.versions << new_version
743 743 end
744 744 end
745 745
746 746 # Copies issue categories from +project+
747 747 def copy_issue_categories(project)
748 748 project.issue_categories.each do |issue_category|
749 749 new_issue_category = IssueCategory.new
750 750 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
751 751 self.issue_categories << new_issue_category
752 752 end
753 753 end
754 754
755 755 # Copies issues from +project+
756 756 # Note: issues assigned to a closed version won't be copied due to validation rules
757 757 def copy_issues(project)
758 758 # Stores the source issue id as a key and the copied issues as the
759 759 # value. Used to map the two togeather for issue relations.
760 760 issues_map = {}
761 761
762 762 # Get issues sorted by root_id, lft so that parent issues
763 763 # get copied before their children
764 764 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
765 765 new_issue = Issue.new
766 766 new_issue.copy_from(issue)
767 767 new_issue.project = self
768 768 # Reassign fixed_versions by name, since names are unique per
769 769 # project and the versions for self are not yet saved
770 770 if issue.fixed_version
771 771 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
772 772 end
773 773 # Reassign the category by name, since names are unique per
774 774 # project and the categories for self are not yet saved
775 775 if issue.category
776 776 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
777 777 end
778 778 # Parent issue
779 779 if issue.parent_id
780 780 if copied_parent = issues_map[issue.parent_id]
781 781 new_issue.parent_issue_id = copied_parent.id
782 782 end
783 783 end
784 784
785 785 self.issues << new_issue
786 786 if new_issue.new_record?
787 787 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
788 788 else
789 789 issues_map[issue.id] = new_issue unless new_issue.new_record?
790 790 end
791 791 end
792 792
793 793 # Relations after in case issues related each other
794 794 project.issues.each do |issue|
795 795 new_issue = issues_map[issue.id]
796 796 unless new_issue
797 797 # Issue was not copied
798 798 next
799 799 end
800 800
801 801 # Relations
802 802 issue.relations_from.each do |source_relation|
803 803 new_issue_relation = IssueRelation.new
804 804 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
805 805 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
806 806 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
807 807 new_issue_relation.issue_to = source_relation.issue_to
808 808 end
809 809 new_issue.relations_from << new_issue_relation
810 810 end
811 811
812 812 issue.relations_to.each do |source_relation|
813 813 new_issue_relation = IssueRelation.new
814 814 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
815 815 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
816 816 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
817 817 new_issue_relation.issue_from = source_relation.issue_from
818 818 end
819 819 new_issue.relations_to << new_issue_relation
820 820 end
821 821 end
822 822 end
823 823
824 824 # Copies members from +project+
825 825 def copy_members(project)
826 826 # Copy users first, then groups to handle members with inherited and given roles
827 827 members_to_copy = []
828 828 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
829 829 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
830 830
831 831 members_to_copy.each do |member|
832 832 new_member = Member.new
833 833 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
834 834 # only copy non inherited roles
835 835 # inherited roles will be added when copying the group membership
836 836 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
837 837 next if role_ids.empty?
838 838 new_member.role_ids = role_ids
839 839 new_member.project = self
840 840 self.members << new_member
841 841 end
842 842 end
843 843
844 844 # Copies queries from +project+
845 845 def copy_queries(project)
846 846 project.queries.each do |query|
847 847 new_query = ::Query.new
848 848 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
849 849 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
850 850 new_query.project = self
851 851 new_query.user_id = query.user_id
852 852 self.queries << new_query
853 853 end
854 854 end
855 855
856 856 # Copies boards from +project+
857 857 def copy_boards(project)
858 858 project.boards.each do |board|
859 859 new_board = Board.new
860 860 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
861 861 new_board.project = self
862 862 self.boards << new_board
863 863 end
864 864 end
865 865
866 866 def allowed_permissions
867 867 @allowed_permissions ||= begin
868 868 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
869 869 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
870 870 end
871 871 end
872 872
873 873 def allowed_actions
874 874 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
875 875 end
876 876
877 877 # Returns all the active Systemwide and project specific activities
878 878 def active_activities
879 879 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
880 880
881 881 if overridden_activity_ids.empty?
882 882 return TimeEntryActivity.shared.active
883 883 else
884 884 return system_activities_and_project_overrides
885 885 end
886 886 end
887 887
888 888 # Returns all the Systemwide and project specific activities
889 889 # (inactive and active)
890 890 def all_activities
891 891 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
892 892
893 893 if overridden_activity_ids.empty?
894 894 return TimeEntryActivity.shared
895 895 else
896 896 return system_activities_and_project_overrides(true)
897 897 end
898 898 end
899 899
900 900 # Returns the systemwide active activities merged with the project specific overrides
901 901 def system_activities_and_project_overrides(include_inactive=false)
902 902 if include_inactive
903 903 return TimeEntryActivity.shared.
904 904 find(:all,
905 905 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
906 906 self.time_entry_activities
907 907 else
908 908 return TimeEntryActivity.shared.active.
909 909 find(:all,
910 910 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
911 911 self.time_entry_activities.active
912 912 end
913 913 end
914 914
915 915 # Archives subprojects recursively
916 916 def archive!
917 917 children.each do |subproject|
918 918 subproject.send :archive!
919 919 end
920 920 update_attribute :status, STATUS_ARCHIVED
921 921 end
922 922 end
@@ -1,865 +1,865
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.default_order = options[:default_order]
30 30 @caption_key = options[:caption] || "field_#{name}"
31 31 end
32 32
33 33 def caption
34 34 l(@caption_key)
35 35 end
36 36
37 37 # Returns true if the column is sortable, otherwise false
38 38 def sortable?
39 39 !@sortable.nil?
40 40 end
41 41
42 42 def sortable
43 43 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 44 end
45 45
46 46 def value(issue)
47 47 issue.send name
48 48 end
49 49
50 50 def css_classes
51 51 name
52 52 end
53 53 end
54 54
55 55 class QueryCustomFieldColumn < QueryColumn
56 56
57 57 def initialize(custom_field)
58 58 self.name = "cf_#{custom_field.id}".to_sym
59 59 self.sortable = custom_field.order_statement || false
60 60 if %w(list date bool int).include?(custom_field.field_format) && !custom_field.multiple?
61 61 self.groupable = custom_field.order_statement
62 62 end
63 63 self.groupable ||= false
64 64 @cf = custom_field
65 65 end
66 66
67 67 def caption
68 68 @cf.name
69 69 end
70 70
71 71 def custom_field
72 72 @cf
73 73 end
74 74
75 75 def value(issue)
76 76 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
77 77 cv.size > 1 ? cv : cv.first
78 78 end
79 79
80 80 def css_classes
81 81 @css_classes ||= "#{name} #{@cf.field_format}"
82 82 end
83 83 end
84 84
85 85 class Query < ActiveRecord::Base
86 86 class StatementInvalid < ::ActiveRecord::StatementInvalid
87 87 end
88 88
89 89 belongs_to :project
90 90 belongs_to :user
91 91 serialize :filters
92 92 serialize :column_names
93 93 serialize :sort_criteria, Array
94 94
95 95 attr_protected :project_id, :user_id
96 96
97 97 validates_presence_of :name
98 98 validates_length_of :name, :maximum => 255
99 99 validate :validate_query_filters
100 100
101 101 @@operators = { "=" => :label_equals,
102 102 "!" => :label_not_equals,
103 103 "o" => :label_open_issues,
104 104 "c" => :label_closed_issues,
105 105 "!*" => :label_none,
106 106 "*" => :label_all,
107 107 ">=" => :label_greater_or_equal,
108 108 "<=" => :label_less_or_equal,
109 109 "><" => :label_between,
110 110 "<t+" => :label_in_less_than,
111 111 ">t+" => :label_in_more_than,
112 112 "t+" => :label_in,
113 113 "t" => :label_today,
114 114 "w" => :label_this_week,
115 115 ">t-" => :label_less_than_ago,
116 116 "<t-" => :label_more_than_ago,
117 117 "t-" => :label_ago,
118 118 "~" => :label_contains,
119 119 "!~" => :label_not_contains }
120 120
121 121 cattr_reader :operators
122 122
123 123 @@operators_by_filter_type = { :list => [ "=", "!" ],
124 124 :list_status => [ "o", "=", "!", "c", "*" ],
125 125 :list_optional => [ "=", "!", "!*", "*" ],
126 126 :list_subprojects => [ "*", "!*", "=" ],
127 127 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-", "!*", "*" ],
128 128 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "t-", "t", "w", "!*", "*" ],
129 129 :string => [ "=", "~", "!", "!~" ],
130 130 :text => [ "~", "!~" ],
131 131 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
132 132 :float => [ "=", ">=", "<=", "><", "!*", "*" ] }
133 133
134 134 cattr_reader :operators_by_filter_type
135 135
136 136 @@available_columns = [
137 137 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
138 138 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
139 139 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
140 140 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
141 141 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
142 142 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
143 143 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
144 144 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
145 145 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
146 146 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
147 147 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
148 148 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
149 149 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
150 150 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
151 151 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
152 152 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
153 153 ]
154 154 cattr_reader :available_columns
155 155
156 named_scope :visible, lambda {|*args|
156 scope :visible, lambda {|*args|
157 157 user = args.shift || User.current
158 158 base = Project.allowed_to_condition(user, :view_issues, *args)
159 159 user_id = user.logged? ? user.id : 0
160 160 {
161 161 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
162 162 :include => :project
163 163 }
164 164 }
165 165
166 166 def initialize(attributes=nil, *args)
167 167 super attributes
168 168 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
169 169 @is_for_all = project.nil?
170 170 end
171 171
172 172 def validate_query_filters
173 173 filters.each_key do |field|
174 174 if values_for(field)
175 175 case type_for(field)
176 176 when :integer
177 177 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
178 178 when :float
179 179 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+(\.\d*)?$/) }
180 180 when :date, :date_past
181 181 case operator_for(field)
182 182 when "=", ">=", "<=", "><"
183 183 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
184 184 when ">t-", "<t-", "t-"
185 185 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
186 186 end
187 187 end
188 188 end
189 189
190 190 add_filter_error(field, :blank) unless
191 191 # filter requires one or more values
192 192 (values_for(field) and !values_for(field).first.blank?) or
193 193 # filter doesn't require any value
194 194 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
195 195 end if filters
196 196 end
197 197
198 198 def add_filter_error(field, message)
199 199 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
200 200 errors.add(:base, m)
201 201 end
202 202
203 203 # Returns true if the query is visible to +user+ or the current user.
204 204 def visible?(user=User.current)
205 205 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
206 206 end
207 207
208 208 def editable_by?(user)
209 209 return false unless user
210 210 # Admin can edit them all and regular users can edit their private queries
211 211 return true if user.admin? || (!is_public && self.user_id == user.id)
212 212 # Members can not edit public queries that are for all project (only admin is allowed to)
213 213 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
214 214 end
215 215
216 216 def available_filters
217 217 return @available_filters if @available_filters
218 218
219 219 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
220 220
221 221 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
222 222 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
223 223 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
224 224 "subject" => { :type => :text, :order => 8 },
225 225 "created_on" => { :type => :date_past, :order => 9 },
226 226 "updated_on" => { :type => :date_past, :order => 10 },
227 227 "start_date" => { :type => :date, :order => 11 },
228 228 "due_date" => { :type => :date, :order => 12 },
229 229 "estimated_hours" => { :type => :float, :order => 13 },
230 230 "done_ratio" => { :type => :integer, :order => 14 }}
231 231
232 232 principals = []
233 233 if project
234 234 principals += project.principals.sort
235 235 unless project.leaf?
236 236 subprojects = project.descendants.visible.all
237 237 if subprojects.any?
238 238 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => subprojects.collect{|s| [s.name, s.id.to_s] } }
239 239 principals += Principal.member_of(subprojects)
240 240 end
241 241 end
242 242 else
243 243 all_projects = Project.visible.all
244 244 if all_projects.any?
245 245 # members of visible projects
246 246 principals += Principal.member_of(all_projects)
247 247
248 248 # project filter
249 249 project_values = []
250 250 if User.current.logged? && User.current.memberships.any?
251 251 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
252 252 end
253 253 Project.project_tree(all_projects) do |p, level|
254 254 prefix = (level > 0 ? ('--' * level + ' ') : '')
255 255 project_values << ["#{prefix}#{p.name}", p.id.to_s]
256 256 end
257 257 @available_filters["project_id"] = { :type => :list, :order => 1, :values => project_values} unless project_values.empty?
258 258 end
259 259 end
260 260 principals.uniq!
261 261 principals.sort!
262 262 users = principals.select {|p| p.is_a?(User)}
263 263
264 264 assigned_to_values = []
265 265 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
266 266 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
267 267 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => assigned_to_values } unless assigned_to_values.empty?
268 268
269 269 author_values = []
270 270 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
271 271 author_values += users.collect{|s| [s.name, s.id.to_s] }
272 272 @available_filters["author_id"] = { :type => :list, :order => 5, :values => author_values } unless author_values.empty?
273 273
274 274 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
275 275 @available_filters["member_of_group"] = { :type => :list_optional, :order => 6, :values => group_values } unless group_values.empty?
276 276
277 277 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
278 278 @available_filters["assigned_to_role"] = { :type => :list_optional, :order => 7, :values => role_values } unless role_values.empty?
279 279
280 280 if User.current.logged?
281 281 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
282 282 end
283 283
284 284 if project
285 285 # project specific filters
286 286 categories = project.issue_categories.all
287 287 unless categories.empty?
288 288 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => categories.collect{|s| [s.name, s.id.to_s] } }
289 289 end
290 290 versions = project.shared_versions.all
291 291 unless versions.empty?
292 292 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
293 293 end
294 294 add_custom_fields_filters(project.all_issue_custom_fields)
295 295 else
296 296 # global filters for cross project issue list
297 297 system_shared_versions = Version.visible.find_all_by_sharing('system')
298 298 unless system_shared_versions.empty?
299 299 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => system_shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
300 300 end
301 301 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
302 302 end
303 303 @available_filters
304 304 end
305 305
306 306 def add_filter(field, operator, values)
307 307 # values must be an array
308 308 return unless values.nil? || values.is_a?(Array)
309 309 # check if field is defined as an available filter
310 310 if available_filters.has_key? field
311 311 filter_options = available_filters[field]
312 312 # check if operator is allowed for that filter
313 313 #if @@operators_by_filter_type[filter_options[:type]].include? operator
314 314 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
315 315 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
316 316 #end
317 317 filters[field] = {:operator => operator, :values => (values || [''])}
318 318 end
319 319 end
320 320
321 321 def add_short_filter(field, expression)
322 322 return unless expression && available_filters.has_key?(field)
323 323 field_type = available_filters[field][:type]
324 324 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
325 325 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
326 326 add_filter field, operator, $1.present? ? $1.split('|') : ['']
327 327 end || add_filter(field, '=', expression.split('|'))
328 328 end
329 329
330 330 # Add multiple filters using +add_filter+
331 331 def add_filters(fields, operators, values)
332 332 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
333 333 fields.each do |field|
334 334 add_filter(field, operators[field], values && values[field])
335 335 end
336 336 end
337 337 end
338 338
339 339 def has_filter?(field)
340 340 filters and filters[field]
341 341 end
342 342
343 343 def type_for(field)
344 344 available_filters[field][:type] if available_filters.has_key?(field)
345 345 end
346 346
347 347 def operator_for(field)
348 348 has_filter?(field) ? filters[field][:operator] : nil
349 349 end
350 350
351 351 def values_for(field)
352 352 has_filter?(field) ? filters[field][:values] : nil
353 353 end
354 354
355 355 def value_for(field, index=0)
356 356 (values_for(field) || [])[index]
357 357 end
358 358
359 359 def label_for(field)
360 360 label = available_filters[field][:name] if available_filters.has_key?(field)
361 361 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
362 362 end
363 363
364 364 def available_columns
365 365 return @available_columns if @available_columns
366 366 @available_columns = ::Query.available_columns.dup
367 367 @available_columns += (project ?
368 368 project.all_issue_custom_fields :
369 369 IssueCustomField.find(:all)
370 370 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
371 371
372 372 if User.current.allowed_to?(:view_time_entries, project, :global => true)
373 373 index = nil
374 374 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
375 375 index = (index ? index + 1 : -1)
376 376 # insert the column after estimated_hours or at the end
377 377 @available_columns.insert index, QueryColumn.new(:spent_hours,
378 378 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
379 379 :default_order => 'desc',
380 380 :caption => :label_spent_time
381 381 )
382 382 end
383 383 @available_columns
384 384 end
385 385
386 386 def self.available_columns=(v)
387 387 self.available_columns = (v)
388 388 end
389 389
390 390 def self.add_available_column(column)
391 391 self.available_columns << (column) if column.is_a?(QueryColumn)
392 392 end
393 393
394 394 # Returns an array of columns that can be used to group the results
395 395 def groupable_columns
396 396 available_columns.select {|c| c.groupable}
397 397 end
398 398
399 399 # Returns a Hash of columns and the key for sorting
400 400 def sortable_columns
401 401 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
402 402 h[column.name.to_s] = column.sortable
403 403 h
404 404 })
405 405 end
406 406
407 407 def columns
408 408 # preserve the column_names order
409 409 (has_default_columns? ? default_columns_names : column_names).collect do |name|
410 410 available_columns.find { |col| col.name == name }
411 411 end.compact
412 412 end
413 413
414 414 def default_columns_names
415 415 @default_columns_names ||= begin
416 416 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
417 417
418 418 project.present? ? default_columns : [:project] | default_columns
419 419 end
420 420 end
421 421
422 422 def column_names=(names)
423 423 if names
424 424 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
425 425 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
426 426 # Set column_names to nil if default columns
427 427 if names == default_columns_names
428 428 names = nil
429 429 end
430 430 end
431 431 write_attribute(:column_names, names)
432 432 end
433 433
434 434 def has_column?(column)
435 435 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
436 436 end
437 437
438 438 def has_default_columns?
439 439 column_names.nil? || column_names.empty?
440 440 end
441 441
442 442 def sort_criteria=(arg)
443 443 c = []
444 444 if arg.is_a?(Hash)
445 445 arg = arg.keys.sort.collect {|k| arg[k]}
446 446 end
447 447 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
448 448 write_attribute(:sort_criteria, c)
449 449 end
450 450
451 451 def sort_criteria
452 452 read_attribute(:sort_criteria) || []
453 453 end
454 454
455 455 def sort_criteria_key(arg)
456 456 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
457 457 end
458 458
459 459 def sort_criteria_order(arg)
460 460 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
461 461 end
462 462
463 463 # Returns the SQL sort order that should be prepended for grouping
464 464 def group_by_sort_order
465 465 if grouped? && (column = group_by_column)
466 466 column.sortable.is_a?(Array) ?
467 467 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
468 468 "#{column.sortable} #{column.default_order}"
469 469 end
470 470 end
471 471
472 472 # Returns true if the query is a grouped query
473 473 def grouped?
474 474 !group_by_column.nil?
475 475 end
476 476
477 477 def group_by_column
478 478 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
479 479 end
480 480
481 481 def group_by_statement
482 482 group_by_column.try(:groupable)
483 483 end
484 484
485 485 def project_statement
486 486 project_clauses = []
487 487 if project && !project.descendants.active.empty?
488 488 ids = [project.id]
489 489 if has_filter?("subproject_id")
490 490 case operator_for("subproject_id")
491 491 when '='
492 492 # include the selected subprojects
493 493 ids += values_for("subproject_id").each(&:to_i)
494 494 when '!*'
495 495 # main project only
496 496 else
497 497 # all subprojects
498 498 ids += project.descendants.collect(&:id)
499 499 end
500 500 elsif Setting.display_subprojects_issues?
501 501 ids += project.descendants.collect(&:id)
502 502 end
503 503 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
504 504 elsif project
505 505 project_clauses << "#{Project.table_name}.id = %d" % project.id
506 506 end
507 507 project_clauses.any? ? project_clauses.join(' AND ') : nil
508 508 end
509 509
510 510 def statement
511 511 # filters clauses
512 512 filters_clauses = []
513 513 filters.each_key do |field|
514 514 next if field == "subproject_id"
515 515 v = values_for(field).clone
516 516 next unless v and !v.empty?
517 517 operator = operator_for(field)
518 518
519 519 # "me" value subsitution
520 520 if %w(assigned_to_id author_id watcher_id).include?(field)
521 521 if v.delete("me")
522 522 if User.current.logged?
523 523 v.push(User.current.id.to_s)
524 524 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
525 525 else
526 526 v.push("0")
527 527 end
528 528 end
529 529 end
530 530
531 531 if field == 'project_id'
532 532 if v.delete('mine')
533 533 v += User.current.memberships.map(&:project_id).map(&:to_s)
534 534 end
535 535 end
536 536
537 537 if field =~ /^cf_(\d+)$/
538 538 # custom field
539 539 filters_clauses << sql_for_custom_field(field, operator, v, $1)
540 540 elsif respond_to?("sql_for_#{field}_field")
541 541 # specific statement
542 542 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
543 543 else
544 544 # regular field
545 545 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
546 546 end
547 547 end if filters and valid?
548 548
549 549 filters_clauses << project_statement
550 550 filters_clauses.reject!(&:blank?)
551 551
552 552 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
553 553 end
554 554
555 555 # Returns the issue count
556 556 def issue_count
557 557 Issue.visible.count(:include => [:status, :project], :conditions => statement)
558 558 rescue ::ActiveRecord::StatementInvalid => e
559 559 raise StatementInvalid.new(e.message)
560 560 end
561 561
562 562 # Returns the issue count by group or nil if query is not grouped
563 563 def issue_count_by_group
564 564 r = nil
565 565 if grouped?
566 566 begin
567 567 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
568 568 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
569 569 rescue ActiveRecord::RecordNotFound
570 570 r = {nil => issue_count}
571 571 end
572 572 c = group_by_column
573 573 if c.is_a?(QueryCustomFieldColumn)
574 574 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
575 575 end
576 576 end
577 577 r
578 578 rescue ::ActiveRecord::StatementInvalid => e
579 579 raise StatementInvalid.new(e.message)
580 580 end
581 581
582 582 # Returns the issues
583 583 # Valid options are :order, :offset, :limit, :include, :conditions
584 584 def issues(options={})
585 585 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
586 586 order_option = nil if order_option.blank?
587 587
588 588 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
589 589
590 590 issues = Issue.visible.scoped(:conditions => options[:conditions]).find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
591 591 :conditions => statement,
592 592 :order => order_option,
593 593 :joins => joins,
594 594 :limit => options[:limit],
595 595 :offset => options[:offset]
596 596
597 597 if has_column?(:spent_hours)
598 598 Issue.load_visible_spent_hours(issues)
599 599 end
600 600 issues
601 601 rescue ::ActiveRecord::StatementInvalid => e
602 602 raise StatementInvalid.new(e.message)
603 603 end
604 604
605 605 # Returns the issues ids
606 606 def issue_ids(options={})
607 607 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
608 608 order_option = nil if order_option.blank?
609 609
610 610 joins = (order_option && order_option.include?('authors')) ? "LEFT OUTER JOIN users authors ON authors.id = #{Issue.table_name}.author_id" : nil
611 611
612 612 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
613 613 :conditions => statement,
614 614 :order => order_option,
615 615 :joins => joins,
616 616 :limit => options[:limit],
617 617 :offset => options[:offset]).find_ids
618 618 rescue ::ActiveRecord::StatementInvalid => e
619 619 raise StatementInvalid.new(e.message)
620 620 end
621 621
622 622 # Returns the journals
623 623 # Valid options are :order, :offset, :limit
624 624 def journals(options={})
625 625 Journal.visible.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
626 626 :conditions => statement,
627 627 :order => options[:order],
628 628 :limit => options[:limit],
629 629 :offset => options[:offset]
630 630 rescue ::ActiveRecord::StatementInvalid => e
631 631 raise StatementInvalid.new(e.message)
632 632 end
633 633
634 634 # Returns the versions
635 635 # Valid options are :conditions
636 636 def versions(options={})
637 637 Version.visible.scoped(:conditions => options[:conditions]).find :all, :include => :project, :conditions => project_statement
638 638 rescue ::ActiveRecord::StatementInvalid => e
639 639 raise StatementInvalid.new(e.message)
640 640 end
641 641
642 642 def sql_for_watcher_id_field(field, operator, value)
643 643 db_table = Watcher.table_name
644 644 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
645 645 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
646 646 end
647 647
648 648 def sql_for_member_of_group_field(field, operator, value)
649 649 if operator == '*' # Any group
650 650 groups = Group.all
651 651 operator = '=' # Override the operator since we want to find by assigned_to
652 652 elsif operator == "!*"
653 653 groups = Group.all
654 654 operator = '!' # Override the operator since we want to find by assigned_to
655 655 else
656 656 groups = Group.find_all_by_id(value)
657 657 end
658 658 groups ||= []
659 659
660 660 members_of_groups = groups.inject([]) {|user_ids, group|
661 661 if group && group.user_ids.present?
662 662 user_ids << group.user_ids
663 663 end
664 664 user_ids.flatten.uniq.compact
665 665 }.sort.collect(&:to_s)
666 666
667 667 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
668 668 end
669 669
670 670 def sql_for_assigned_to_role_field(field, operator, value)
671 671 case operator
672 672 when "*", "!*" # Member / Not member
673 673 sw = operator == "!*" ? 'NOT' : ''
674 674 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
675 675 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
676 676 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
677 677 when "=", "!"
678 678 role_cond = value.any? ?
679 679 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
680 680 "1=0"
681 681
682 682 sw = operator == "!" ? 'NOT' : ''
683 683 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
684 684 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
685 685 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
686 686 end
687 687 end
688 688
689 689 private
690 690
691 691 def sql_for_custom_field(field, operator, value, custom_field_id)
692 692 db_table = CustomValue.table_name
693 693 db_field = 'value'
694 694 filter = @available_filters[field]
695 695 if filter && filter[:format] == 'user'
696 696 if value.delete('me')
697 697 value.push User.current.id.to_s
698 698 end
699 699 end
700 700 not_in = nil
701 701 if operator == '!'
702 702 # Makes ! operator work for custom fields with multiple values
703 703 operator = '='
704 704 not_in = 'NOT'
705 705 end
706 706 "#{Issue.table_name}.id #{not_in} IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
707 707 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
708 708 end
709 709
710 710 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
711 711 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
712 712 sql = ''
713 713 case operator
714 714 when "="
715 715 if value.any?
716 716 case type_for(field)
717 717 when :date, :date_past
718 718 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
719 719 when :integer
720 720 if is_custom_filter
721 721 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
722 722 else
723 723 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
724 724 end
725 725 when :float
726 726 if is_custom_filter
727 727 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
728 728 else
729 729 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
730 730 end
731 731 else
732 732 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
733 733 end
734 734 else
735 735 # IN an empty set
736 736 sql = "1=0"
737 737 end
738 738 when "!"
739 739 if value.any?
740 740 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
741 741 else
742 742 # NOT IN an empty set
743 743 sql = "1=1"
744 744 end
745 745 when "!*"
746 746 sql = "#{db_table}.#{db_field} IS NULL"
747 747 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
748 748 when "*"
749 749 sql = "#{db_table}.#{db_field} IS NOT NULL"
750 750 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
751 751 when ">="
752 752 if [:date, :date_past].include?(type_for(field))
753 753 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
754 754 else
755 755 if is_custom_filter
756 756 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
757 757 else
758 758 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
759 759 end
760 760 end
761 761 when "<="
762 762 if [:date, :date_past].include?(type_for(field))
763 763 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
764 764 else
765 765 if is_custom_filter
766 766 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
767 767 else
768 768 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
769 769 end
770 770 end
771 771 when "><"
772 772 if [:date, :date_past].include?(type_for(field))
773 773 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
774 774 else
775 775 if is_custom_filter
776 776 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
777 777 else
778 778 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
779 779 end
780 780 end
781 781 when "o"
782 782 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
783 783 when "c"
784 784 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
785 785 when ">t-"
786 786 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
787 787 when "<t-"
788 788 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
789 789 when "t-"
790 790 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
791 791 when ">t+"
792 792 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
793 793 when "<t+"
794 794 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
795 795 when "t+"
796 796 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
797 797 when "t"
798 798 sql = relative_date_clause(db_table, db_field, 0, 0)
799 799 when "w"
800 800 first_day_of_week = l(:general_first_day_of_week).to_i
801 801 day_of_week = Date.today.cwday
802 802 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
803 803 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
804 804 when "~"
805 805 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
806 806 when "!~"
807 807 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
808 808 else
809 809 raise "Unknown query operator #{operator}"
810 810 end
811 811
812 812 return sql
813 813 end
814 814
815 815 def add_custom_fields_filters(custom_fields)
816 816 @available_filters ||= {}
817 817
818 818 custom_fields.select(&:is_filter?).each do |field|
819 819 case field.field_format
820 820 when "text"
821 821 options = { :type => :text, :order => 20 }
822 822 when "list"
823 823 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
824 824 when "date"
825 825 options = { :type => :date, :order => 20 }
826 826 when "bool"
827 827 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
828 828 when "int"
829 829 options = { :type => :integer, :order => 20 }
830 830 when "float"
831 831 options = { :type => :float, :order => 20 }
832 832 when "user", "version"
833 833 next unless project
834 834 values = field.possible_values_options(project)
835 835 if User.current.logged? && field.field_format == 'user'
836 836 values.unshift ["<< #{l(:label_me)} >>", "me"]
837 837 end
838 838 options = { :type => :list_optional, :values => values, :order => 20}
839 839 else
840 840 options = { :type => :string, :order => 20 }
841 841 end
842 842 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name, :format => field.field_format })
843 843 end
844 844 end
845 845
846 846 # Returns a SQL clause for a date or datetime field.
847 847 def date_clause(table, field, from, to)
848 848 s = []
849 849 if from
850 850 from_yesterday = from - 1
851 851 from_yesterday_utc = Time.gm(from_yesterday.year, from_yesterday.month, from_yesterday.day)
852 852 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_utc.end_of_day)])
853 853 end
854 854 if to
855 855 to_utc = Time.gm(to.year, to.month, to.day)
856 856 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_utc.end_of_day)])
857 857 end
858 858 s.join(' AND ')
859 859 end
860 860
861 861 # Returns a SQL clause for a date or datetime field using relative dates.
862 862 def relative_date_clause(table, field, days_from, days_to)
863 863 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
864 864 end
865 865 end
@@ -1,177 +1,177
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Role < ActiveRecord::Base
19 19 # Built-in roles
20 20 BUILTIN_NON_MEMBER = 1
21 21 BUILTIN_ANONYMOUS = 2
22 22
23 23 ISSUES_VISIBILITY_OPTIONS = [
24 24 ['all', :label_issues_visibility_all],
25 25 ['default', :label_issues_visibility_public],
26 26 ['own', :label_issues_visibility_own]
27 27 ]
28 28
29 named_scope :sorted, {:order => 'builtin, position'}
30 named_scope :givable, { :conditions => "builtin = 0", :order => 'position' }
31 named_scope :builtin, lambda { |*args|
29 scope :sorted, {:order => 'builtin, position'}
30 scope :givable, { :conditions => "builtin = 0", :order => 'position' }
31 scope :builtin, lambda { |*args|
32 32 compare = 'not' if args.first == true
33 33 { :conditions => "#{compare} builtin = 0" }
34 34 }
35 35
36 36 before_destroy :check_deletable
37 37 has_many :workflows, :dependent => :delete_all do
38 38 def copy(source_role)
39 39 Workflow.copy(nil, source_role, nil, proxy_association.owner)
40 40 end
41 41 end
42 42
43 43 has_many :member_roles, :dependent => :destroy
44 44 has_many :members, :through => :member_roles
45 45 acts_as_list
46 46
47 47 serialize :permissions, Array
48 48 attr_protected :builtin
49 49
50 50 validates_presence_of :name
51 51 validates_uniqueness_of :name
52 52 validates_length_of :name, :maximum => 30
53 53 validates_inclusion_of :issues_visibility,
54 54 :in => ISSUES_VISIBILITY_OPTIONS.collect(&:first),
55 55 :if => lambda {|role| role.respond_to?(:issues_visibility)}
56 56
57 57 def permissions
58 58 read_attribute(:permissions) || []
59 59 end
60 60
61 61 def permissions=(perms)
62 62 perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
63 63 write_attribute(:permissions, perms)
64 64 end
65 65
66 66 def add_permission!(*perms)
67 67 self.permissions = [] unless permissions.is_a?(Array)
68 68
69 69 permissions_will_change!
70 70 perms.each do |p|
71 71 p = p.to_sym
72 72 permissions << p unless permissions.include?(p)
73 73 end
74 74 save!
75 75 end
76 76
77 77 def remove_permission!(*perms)
78 78 return unless permissions.is_a?(Array)
79 79 permissions_will_change!
80 80 perms.each { |p| permissions.delete(p.to_sym) }
81 81 save!
82 82 end
83 83
84 84 # Returns true if the role has the given permission
85 85 def has_permission?(perm)
86 86 !permissions.nil? && permissions.include?(perm.to_sym)
87 87 end
88 88
89 89 def <=>(role)
90 90 role ? position <=> role.position : -1
91 91 end
92 92
93 93 def to_s
94 94 name
95 95 end
96 96
97 97 def name
98 98 case builtin
99 99 when 1; l(:label_role_non_member, :default => read_attribute(:name))
100 100 when 2; l(:label_role_anonymous, :default => read_attribute(:name))
101 101 else; read_attribute(:name)
102 102 end
103 103 end
104 104
105 105 # Return true if the role is a builtin role
106 106 def builtin?
107 107 self.builtin != 0
108 108 end
109 109
110 110 # Return true if the role is a project member role
111 111 def member?
112 112 !self.builtin?
113 113 end
114 114
115 115 # Return true if role is allowed to do the specified action
116 116 # action can be:
117 117 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
118 118 # * a permission Symbol (eg. :edit_project)
119 119 def allowed_to?(action)
120 120 if action.is_a? Hash
121 121 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
122 122 else
123 123 allowed_permissions.include? action
124 124 end
125 125 end
126 126
127 127 # Return all the permissions that can be given to the role
128 128 def setable_permissions
129 129 setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
130 130 setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
131 131 setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
132 132 setable_permissions
133 133 end
134 134
135 135 # Find all the roles that can be given to a project member
136 136 def self.find_all_givable
137 137 find(:all, :conditions => {:builtin => 0}, :order => 'position')
138 138 end
139 139
140 140 # Return the builtin 'non member' role. If the role doesn't exist,
141 141 # it will be created on the fly.
142 142 def self.non_member
143 143 find_or_create_system_role(BUILTIN_NON_MEMBER, 'Non member')
144 144 end
145 145
146 146 # Return the builtin 'anonymous' role. If the role doesn't exist,
147 147 # it will be created on the fly.
148 148 def self.anonymous
149 149 find_or_create_system_role(BUILTIN_ANONYMOUS, 'Anonymous')
150 150 end
151 151
152 152 private
153 153
154 154 def allowed_permissions
155 155 @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
156 156 end
157 157
158 158 def allowed_actions
159 159 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
160 160 end
161 161
162 162 def check_deletable
163 163 raise "Can't delete role" if members.any?
164 164 raise "Can't delete builtin role" if builtin?
165 165 end
166 166
167 167 def self.find_or_create_system_role(builtin, name)
168 168 role = first(:conditions => {:builtin => builtin})
169 169 if role.nil?
170 170 role = create(:name => name, :position => 0) do |r|
171 171 r.builtin = builtin
172 172 end
173 173 raise "Unable to create the #{name} role." if role.new_record?
174 174 end
175 175 role
176 176 end
177 177 end
@@ -1,120 +1,120
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class TimeEntry < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 # could have used polymorphic association
21 21 # project association here allows easy loading of time entries at project level with one database trip
22 22 belongs_to :project
23 23 belongs_to :issue
24 24 belongs_to :user
25 25 belongs_to :activity, :class_name => 'TimeEntryActivity', :foreign_key => 'activity_id'
26 26
27 27 attr_protected :project_id, :user_id, :tyear, :tmonth, :tweek
28 28
29 29 acts_as_customizable
30 30 acts_as_event :title => Proc.new {|o| "#{l_hours(o.hours)} (#{(o.issue || o.project).event_title})"},
31 31 :url => Proc.new {|o| {:controller => 'timelog', :action => 'index', :project_id => o.project, :issue_id => o.issue}},
32 32 :author => :user,
33 33 :description => :comments
34 34
35 35 acts_as_activity_provider :timestamp => "#{table_name}.created_on",
36 36 :author_key => :user_id,
37 37 :find_options => {:include => :project}
38 38
39 39 validates_presence_of :user_id, :activity_id, :project_id, :hours, :spent_on
40 40 validates_numericality_of :hours, :allow_nil => true, :message => :invalid
41 41 validates_length_of :comments, :maximum => 255, :allow_nil => true
42 42 before_validation :set_project_if_nil
43 43 validate :validate_time_entry
44 44
45 named_scope :visible, lambda {|*args| {
45 scope :visible, lambda {|*args| {
46 46 :include => :project,
47 47 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
48 48 }}
49 named_scope :on_issue, lambda {|issue| {
49 scope :on_issue, lambda {|issue| {
50 50 :include => :issue,
51 51 :conditions => "#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}"
52 52 }}
53 named_scope :on_project, lambda {|project, include_subprojects| {
53 scope :on_project, lambda {|project, include_subprojects| {
54 54 :include => :project,
55 55 :conditions => project.project_condition(include_subprojects)
56 56 }}
57 named_scope :spent_between, lambda {|from, to|
57 scope :spent_between, lambda {|from, to|
58 58 if from && to
59 59 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
60 60 elsif from
61 61 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
62 62 elsif to
63 63 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
64 64 else
65 65 {}
66 66 end
67 67 }
68 68
69 69 safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values'
70 70
71 71 def initialize(attributes=nil, *args)
72 72 super
73 73 if new_record? && self.activity.nil?
74 74 if default_activity = TimeEntryActivity.default
75 75 self.activity_id = default_activity.id
76 76 end
77 77 self.hours = nil if hours == 0
78 78 end
79 79 end
80 80
81 81 def set_project_if_nil
82 82 self.project = issue.project if issue && project.nil?
83 83 end
84 84
85 85 def validate_time_entry
86 86 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
87 87 errors.add :project_id, :invalid if project.nil?
88 88 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
89 89 end
90 90
91 91 def hours=(h)
92 92 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
93 93 end
94 94
95 95 def hours
96 96 h = read_attribute(:hours)
97 97 if h.is_a?(Float)
98 98 h.round(2)
99 99 else
100 100 h
101 101 end
102 102 end
103 103
104 104 # tyear, tmonth, tweek assigned where setting spent_on attributes
105 105 # these attributes make time aggregations easier
106 106 def spent_on=(date)
107 107 super
108 108 if spent_on.is_a?(Time)
109 109 self.spent_on = spent_on.to_date
110 110 end
111 111 self.tyear = spent_on ? spent_on.year : nil
112 112 self.tmonth = spent_on ? spent_on.month : nil
113 113 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
114 114 end
115 115
116 116 # Returns true if the time entry can be edited by usr, otherwise false
117 117 def editable_by?(usr)
118 118 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
119 119 end
120 120 end
@@ -1,68 +1,68
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Tracker < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 20 has_many :issues
21 21 has_many :workflows, :dependent => :delete_all do
22 22 def copy(source_tracker)
23 23 Workflow.copy(source_tracker, nil, proxy_association.owner, nil)
24 24 end
25 25 end
26 26
27 27 has_and_belongs_to_many :projects
28 28 has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField', :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}", :association_foreign_key => 'custom_field_id'
29 29 acts_as_list
30 30
31 31 validates_presence_of :name
32 32 validates_uniqueness_of :name
33 33 validates_length_of :name, :maximum => 30
34 34
35 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
35 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
36 36
37 37 def to_s; name end
38 38
39 39 def <=>(tracker)
40 40 name <=> tracker.name
41 41 end
42 42
43 43 def self.all
44 44 find(:all, :order => 'position')
45 45 end
46 46
47 47 # Returns an array of IssueStatus that are used
48 48 # in the tracker's workflows
49 49 def issue_statuses
50 50 if @issue_statuses
51 51 return @issue_statuses
52 52 elsif new_record?
53 53 return []
54 54 end
55 55
56 56 ids = Workflow.
57 57 connection.select_rows("SELECT DISTINCT old_status_id, new_status_id FROM #{Workflow.table_name} WHERE tracker_id = #{id}").
58 58 flatten.
59 59 uniq
60 60
61 61 @issue_statuses = IssueStatus.find_all_by_id(ids).sort
62 62 end
63 63
64 64 private
65 65 def check_integrity
66 66 raise "Can't delete tracker" if Issue.find(:first, :conditions => ["tracker_id=?", self.id])
67 67 end
68 68 end
@@ -1,657 +1,657
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Account statuses
24 24 STATUS_ANONYMOUS = 0
25 25 STATUS_ACTIVE = 1
26 26 STATUS_REGISTERED = 2
27 27 STATUS_LOCKED = 3
28 28
29 29 # Different ways of displaying/sorting users
30 30 USER_FORMATS = {
31 31 :firstname_lastname => {:string => '#{firstname} #{lastname}', :order => %w(firstname lastname id)},
32 32 :firstname => {:string => '#{firstname}', :order => %w(firstname id)},
33 33 :lastname_firstname => {:string => '#{lastname} #{firstname}', :order => %w(lastname firstname id)},
34 34 :lastname_coma_firstname => {:string => '#{lastname}, #{firstname}', :order => %w(lastname firstname id)},
35 35 :username => {:string => '#{login}', :order => %w(login id)},
36 36 }
37 37
38 38 MAIL_NOTIFICATION_OPTIONS = [
39 39 ['all', :label_user_mail_option_all],
40 40 ['selected', :label_user_mail_option_selected],
41 41 ['only_my_events', :label_user_mail_option_only_my_events],
42 42 ['only_assigned', :label_user_mail_option_only_assigned],
43 43 ['only_owner', :label_user_mail_option_only_owner],
44 44 ['none', :label_user_mail_option_none]
45 45 ]
46 46
47 47 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
48 48 :after_remove => Proc.new {|user, group| group.user_removed(user)}
49 49 has_many :changesets, :dependent => :nullify
50 50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
51 51 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
52 52 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
53 53 belongs_to :auth_source
54 54
55 55 # Active non-anonymous users scope
56 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
57 named_scope :logged, :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}"
58 named_scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
56 scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
57 scope :logged, :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}"
58 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
59 59
60 60 acts_as_customizable
61 61
62 62 attr_accessor :password, :password_confirmation
63 63 attr_accessor :last_before_login_on
64 64 # Prevents unauthorized assignments
65 65 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
66 66
67 67 LOGIN_LENGTH_LIMIT = 60
68 68 MAIL_LENGTH_LIMIT = 60
69 69
70 70 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
71 71 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
72 72 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
73 73 # Login must contain lettres, numbers, underscores only
74 74 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
75 75 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
76 76 validates_length_of :firstname, :lastname, :maximum => 30
77 77 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
78 78 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
79 79 validates_confirmation_of :password, :allow_nil => true
80 80 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
81 81 validate :validate_password_length
82 82
83 83 before_create :set_mail_notification
84 84 before_save :update_hashed_password
85 85 before_destroy :remove_references_before_destroy
86 86
87 named_scope :in_group, lambda {|group|
87 scope :in_group, lambda {|group|
88 88 group_id = group.is_a?(Group) ? group.id : group.to_i
89 89 { :conditions => ["#{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] }
90 90 }
91 named_scope :not_in_group, lambda {|group|
91 scope :not_in_group, lambda {|group|
92 92 group_id = group.is_a?(Group) ? group.id : group.to_i
93 93 { :conditions => ["#{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] }
94 94 }
95 95
96 96 def set_mail_notification
97 97 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
98 98 true
99 99 end
100 100
101 101 def update_hashed_password
102 102 # update hashed_password if password was set
103 103 if self.password && self.auth_source_id.blank?
104 104 salt_password(password)
105 105 end
106 106 end
107 107
108 108 def reload(*args)
109 109 @name = nil
110 110 @projects_by_role = nil
111 111 super
112 112 end
113 113
114 114 def mail=(arg)
115 115 write_attribute(:mail, arg.to_s.strip)
116 116 end
117 117
118 118 def identity_url=(url)
119 119 if url.blank?
120 120 write_attribute(:identity_url, '')
121 121 else
122 122 begin
123 123 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
124 124 rescue OpenIdAuthentication::InvalidOpenId
125 125 # Invlaid url, don't save
126 126 end
127 127 end
128 128 self.read_attribute(:identity_url)
129 129 end
130 130
131 131 # Returns the user that matches provided login and password, or nil
132 132 def self.try_to_login(login, password)
133 133 # Make sure no one can sign in with an empty password
134 134 return nil if password.to_s.empty?
135 135 user = find_by_login(login)
136 136 if user
137 137 # user is already in local database
138 138 return nil if !user.active?
139 139 if user.auth_source
140 140 # user has an external authentication method
141 141 return nil unless user.auth_source.authenticate(login, password)
142 142 else
143 143 # authentication with local password
144 144 return nil unless user.check_password?(password)
145 145 end
146 146 else
147 147 # user is not yet registered, try to authenticate with available sources
148 148 attrs = AuthSource.authenticate(login, password)
149 149 if attrs
150 150 user = new(attrs)
151 151 user.login = login
152 152 user.language = Setting.default_language
153 153 if user.save
154 154 user.reload
155 155 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
156 156 end
157 157 end
158 158 end
159 159 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
160 160 user
161 161 rescue => text
162 162 raise text
163 163 end
164 164
165 165 # Returns the user who matches the given autologin +key+ or nil
166 166 def self.try_to_autologin(key)
167 167 tokens = Token.find_all_by_action_and_value('autologin', key)
168 168 # Make sure there's only 1 token that matches the key
169 169 if tokens.size == 1
170 170 token = tokens.first
171 171 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
172 172 token.user.update_attribute(:last_login_on, Time.now)
173 173 token.user
174 174 end
175 175 end
176 176 end
177 177
178 178 def self.name_formatter(formatter = nil)
179 179 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
180 180 end
181 181
182 182 # Returns an array of fields names than can be used to make an order statement for users
183 183 # according to how user names are displayed
184 184 # Examples:
185 185 #
186 186 # User.fields_for_order_statement => ['users.login', 'users.id']
187 187 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
188 188 def self.fields_for_order_statement(table=nil)
189 189 table ||= table_name
190 190 name_formatter[:order].map {|field| "#{table}.#{field}"}
191 191 end
192 192
193 193 # Return user's full name for display
194 194 def name(formatter = nil)
195 195 f = self.class.name_formatter(formatter)
196 196 if formatter
197 197 eval('"' + f[:string] + '"')
198 198 else
199 199 @name ||= eval('"' + f[:string] + '"')
200 200 end
201 201 end
202 202
203 203 def active?
204 204 self.status == STATUS_ACTIVE
205 205 end
206 206
207 207 def registered?
208 208 self.status == STATUS_REGISTERED
209 209 end
210 210
211 211 def locked?
212 212 self.status == STATUS_LOCKED
213 213 end
214 214
215 215 def activate
216 216 self.status = STATUS_ACTIVE
217 217 end
218 218
219 219 def register
220 220 self.status = STATUS_REGISTERED
221 221 end
222 222
223 223 def lock
224 224 self.status = STATUS_LOCKED
225 225 end
226 226
227 227 def activate!
228 228 update_attribute(:status, STATUS_ACTIVE)
229 229 end
230 230
231 231 def register!
232 232 update_attribute(:status, STATUS_REGISTERED)
233 233 end
234 234
235 235 def lock!
236 236 update_attribute(:status, STATUS_LOCKED)
237 237 end
238 238
239 239 # Returns true if +clear_password+ is the correct user's password, otherwise false
240 240 def check_password?(clear_password)
241 241 if auth_source_id.present?
242 242 auth_source.authenticate(self.login, clear_password)
243 243 else
244 244 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
245 245 end
246 246 end
247 247
248 248 # Generates a random salt and computes hashed_password for +clear_password+
249 249 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
250 250 def salt_password(clear_password)
251 251 self.salt = User.generate_salt
252 252 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
253 253 end
254 254
255 255 # Does the backend storage allow this user to change their password?
256 256 def change_password_allowed?
257 257 return true if auth_source.nil?
258 258 return auth_source.allow_password_changes?
259 259 end
260 260
261 261 # Generate and set a random password. Useful for automated user creation
262 262 # Based on Token#generate_token_value
263 263 #
264 264 def random_password
265 265 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
266 266 password = ''
267 267 40.times { |i| password << chars[rand(chars.size-1)] }
268 268 self.password = password
269 269 self.password_confirmation = password
270 270 self
271 271 end
272 272
273 273 def pref
274 274 self.preference ||= UserPreference.new(:user => self)
275 275 end
276 276
277 277 def time_zone
278 278 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
279 279 end
280 280
281 281 def wants_comments_in_reverse_order?
282 282 self.pref[:comments_sorting] == 'desc'
283 283 end
284 284
285 285 # Return user's RSS key (a 40 chars long string), used to access feeds
286 286 def rss_key
287 287 if rss_token.nil?
288 288 create_rss_token(:action => 'feeds')
289 289 end
290 290 rss_token.value
291 291 end
292 292
293 293 # Return user's API key (a 40 chars long string), used to access the API
294 294 def api_key
295 295 if api_token.nil?
296 296 create_api_token(:action => 'api')
297 297 end
298 298 api_token.value
299 299 end
300 300
301 301 # Return an array of project ids for which the user has explicitly turned mail notifications on
302 302 def notified_projects_ids
303 303 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
304 304 end
305 305
306 306 def notified_project_ids=(ids)
307 307 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
308 308 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
309 309 @notified_projects_ids = nil
310 310 notified_projects_ids
311 311 end
312 312
313 313 def valid_notification_options
314 314 self.class.valid_notification_options(self)
315 315 end
316 316
317 317 # Only users that belong to more than 1 project can select projects for which they are notified
318 318 def self.valid_notification_options(user=nil)
319 319 # Note that @user.membership.size would fail since AR ignores
320 320 # :include association option when doing a count
321 321 if user.nil? || user.memberships.length < 1
322 322 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
323 323 else
324 324 MAIL_NOTIFICATION_OPTIONS
325 325 end
326 326 end
327 327
328 328 # Find a user account by matching the exact login and then a case-insensitive
329 329 # version. Exact matches will be given priority.
330 330 def self.find_by_login(login)
331 331 # First look for an exact match
332 332 user = all(:conditions => {:login => login}).detect {|u| u.login == login}
333 333 unless user
334 334 # Fail over to case-insensitive if none was found
335 335 user = first(:conditions => ["LOWER(login) = ?", login.to_s.downcase])
336 336 end
337 337 user
338 338 end
339 339
340 340 def self.find_by_rss_key(key)
341 341 token = Token.find_by_value(key)
342 342 token && token.user.active? ? token.user : nil
343 343 end
344 344
345 345 def self.find_by_api_key(key)
346 346 token = Token.find_by_action_and_value('api', key)
347 347 token && token.user.active? ? token.user : nil
348 348 end
349 349
350 350 # Makes find_by_mail case-insensitive
351 351 def self.find_by_mail(mail)
352 352 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
353 353 end
354 354
355 355 # Returns true if the default admin account can no longer be used
356 356 def self.default_admin_account_changed?
357 357 !User.active.find_by_login("admin").try(:check_password?, "admin")
358 358 end
359 359
360 360 def to_s
361 361 name
362 362 end
363 363
364 364 # Returns the current day according to user's time zone
365 365 def today
366 366 if time_zone.nil?
367 367 Date.today
368 368 else
369 369 Time.now.in_time_zone(time_zone).to_date
370 370 end
371 371 end
372 372
373 373 def logged?
374 374 true
375 375 end
376 376
377 377 def anonymous?
378 378 !logged?
379 379 end
380 380
381 381 # Return user's roles for project
382 382 def roles_for_project(project)
383 383 roles = []
384 384 # No role on archived projects
385 385 return roles unless project && project.active?
386 386 if logged?
387 387 # Find project membership
388 388 membership = memberships.detect {|m| m.project_id == project.id}
389 389 if membership
390 390 roles = membership.roles
391 391 else
392 392 @role_non_member ||= Role.non_member
393 393 roles << @role_non_member
394 394 end
395 395 else
396 396 @role_anonymous ||= Role.anonymous
397 397 roles << @role_anonymous
398 398 end
399 399 roles
400 400 end
401 401
402 402 # Return true if the user is a member of project
403 403 def member_of?(project)
404 404 !roles_for_project(project).detect {|role| role.member?}.nil?
405 405 end
406 406
407 407 # Returns a hash of user's projects grouped by roles
408 408 def projects_by_role
409 409 return @projects_by_role if @projects_by_role
410 410
411 411 @projects_by_role = Hash.new {|h,k| h[k]=[]}
412 412 memberships.each do |membership|
413 413 membership.roles.each do |role|
414 414 @projects_by_role[role] << membership.project if membership.project
415 415 end
416 416 end
417 417 @projects_by_role.each do |role, projects|
418 418 projects.uniq!
419 419 end
420 420
421 421 @projects_by_role
422 422 end
423 423
424 424 # Returns true if user is arg or belongs to arg
425 425 def is_or_belongs_to?(arg)
426 426 if arg.is_a?(User)
427 427 self == arg
428 428 elsif arg.is_a?(Group)
429 429 arg.users.include?(self)
430 430 else
431 431 false
432 432 end
433 433 end
434 434
435 435 # Return true if the user is allowed to do the specified action on a specific context
436 436 # Action can be:
437 437 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
438 438 # * a permission Symbol (eg. :edit_project)
439 439 # Context can be:
440 440 # * a project : returns true if user is allowed to do the specified action on this project
441 441 # * an array of projects : returns true if user is allowed on every project
442 442 # * nil with options[:global] set : check if user has at least one role allowed for this action,
443 443 # or falls back to Non Member / Anonymous permissions depending if the user is logged
444 444 def allowed_to?(action, context, options={}, &block)
445 445 if context && context.is_a?(Project)
446 446 # No action allowed on archived projects
447 447 return false unless context.active?
448 448 # No action allowed on disabled modules
449 449 return false unless context.allows_to?(action)
450 450 # Admin users are authorized for anything else
451 451 return true if admin?
452 452
453 453 roles = roles_for_project(context)
454 454 return false unless roles
455 455 roles.detect {|role|
456 456 (context.is_public? || role.member?) &&
457 457 role.allowed_to?(action) &&
458 458 (block_given? ? yield(role, self) : true)
459 459 }
460 460 elsif context && context.is_a?(Array)
461 461 # Authorize if user is authorized on every element of the array
462 462 context.map do |project|
463 463 allowed_to?(action, project, options, &block)
464 464 end.inject do |memo,allowed|
465 465 memo && allowed
466 466 end
467 467 elsif options[:global]
468 468 # Admin users are always authorized
469 469 return true if admin?
470 470
471 471 # authorize if user has at least one role that has this permission
472 472 roles = memberships.collect {|m| m.roles}.flatten.uniq
473 473 roles << (self.logged? ? Role.non_member : Role.anonymous)
474 474 roles.detect {|role|
475 475 role.allowed_to?(action) &&
476 476 (block_given? ? yield(role, self) : true)
477 477 }
478 478 else
479 479 false
480 480 end
481 481 end
482 482
483 483 # Is the user allowed to do the specified action on any project?
484 484 # See allowed_to? for the actions and valid options.
485 485 def allowed_to_globally?(action, options, &block)
486 486 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
487 487 end
488 488
489 489 # Returns true if the user is allowed to delete his own account
490 490 def own_account_deletable?
491 491 Setting.unsubscribe? &&
492 492 (!admin? || User.active.first(:conditions => ["admin = ? AND id <> ?", true, id]).present?)
493 493 end
494 494
495 495 safe_attributes 'login',
496 496 'firstname',
497 497 'lastname',
498 498 'mail',
499 499 'mail_notification',
500 500 'language',
501 501 'custom_field_values',
502 502 'custom_fields',
503 503 'identity_url'
504 504
505 505 safe_attributes 'status',
506 506 'auth_source_id',
507 507 :if => lambda {|user, current_user| current_user.admin?}
508 508
509 509 safe_attributes 'group_ids',
510 510 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
511 511
512 512 # Utility method to help check if a user should be notified about an
513 513 # event.
514 514 #
515 515 # TODO: only supports Issue events currently
516 516 def notify_about?(object)
517 517 case mail_notification
518 518 when 'all'
519 519 true
520 520 when 'selected'
521 521 # user receives notifications for created/assigned issues on unselected projects
522 522 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
523 523 true
524 524 else
525 525 false
526 526 end
527 527 when 'none'
528 528 false
529 529 when 'only_my_events'
530 530 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
531 531 true
532 532 else
533 533 false
534 534 end
535 535 when 'only_assigned'
536 536 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
537 537 true
538 538 else
539 539 false
540 540 end
541 541 when 'only_owner'
542 542 if object.is_a?(Issue) && object.author == self
543 543 true
544 544 else
545 545 false
546 546 end
547 547 else
548 548 false
549 549 end
550 550 end
551 551
552 552 def self.current=(user)
553 553 @current_user = user
554 554 end
555 555
556 556 def self.current
557 557 @current_user ||= User.anonymous
558 558 end
559 559
560 560 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
561 561 # one anonymous user per database.
562 562 def self.anonymous
563 563 anonymous_user = AnonymousUser.find(:first)
564 564 if anonymous_user.nil?
565 565 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
566 566 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
567 567 end
568 568 anonymous_user
569 569 end
570 570
571 571 # Salts all existing unsalted passwords
572 572 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
573 573 # This method is used in the SaltPasswords migration and is to be kept as is
574 574 def self.salt_unsalted_passwords!
575 575 transaction do
576 576 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
577 577 next if user.hashed_password.blank?
578 578 salt = User.generate_salt
579 579 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
580 580 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
581 581 end
582 582 end
583 583 end
584 584
585 585 protected
586 586
587 587 def validate_password_length
588 588 # Password length validation based on setting
589 589 if !password.nil? && password.size < Setting.password_min_length.to_i
590 590 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
591 591 end
592 592 end
593 593
594 594 private
595 595
596 596 # Removes references that are not handled by associations
597 597 # Things that are not deleted are reassociated with the anonymous user
598 598 def remove_references_before_destroy
599 599 return if self.id.nil?
600 600
601 601 substitute = User.anonymous
602 602 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
603 603 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
604 604 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
605 605 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
606 606 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
607 607 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
608 608 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
609 609 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
610 610 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
611 611 # Remove private queries and keep public ones
612 612 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
613 613 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
614 614 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
615 615 Token.delete_all ['user_id = ?', id]
616 616 Watcher.delete_all ['user_id = ?', id]
617 617 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
618 618 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
619 619 end
620 620
621 621 # Return password digest
622 622 def self.hash_password(clear_password)
623 623 Digest::SHA1.hexdigest(clear_password || "")
624 624 end
625 625
626 626 # Returns a 128bits random salt as a hex string (32 chars long)
627 627 def self.generate_salt
628 628 Redmine::Utils.random_hex(16)
629 629 end
630 630
631 631 end
632 632
633 633 class AnonymousUser < User
634 634 validate :validate_anonymous_uniqueness, :on => :create
635 635
636 636 def validate_anonymous_uniqueness
637 637 # There should be only one AnonymousUser in the database
638 638 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.find(:first)
639 639 end
640 640
641 641 def available_custom_fields
642 642 []
643 643 end
644 644
645 645 # Overrides a few properties
646 646 def logged?; false end
647 647 def admin; false end
648 648 def name(*args); I18n.t(:label_user_anonymous) end
649 649 def mail; nil end
650 650 def time_zone; nil end
651 651 def rss_key; nil end
652 652
653 653 # Anonymous user can not be destroyed
654 654 def destroy
655 655 false
656 656 end
657 657 end
@@ -1,271 +1,271
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class 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 33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :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
37 named_scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
38 named_scope :open, :conditions => {:status => 'open'}
39 named_scope :visible, lambda {|*args| { :include => :project,
37 scope :named, lambda {|arg| { :conditions => ["LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip]}}
38 scope :open, :conditions => {:status => 'open'}
39 scope :visible, lambda {|*args| { :include => :project,
40 40 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
41 41
42 42 safe_attributes 'name',
43 43 'description',
44 44 'effective_date',
45 45 'due_date',
46 46 'wiki_page_title',
47 47 'status',
48 48 'sharing',
49 49 'custom_field_values'
50 50
51 51 # Returns true if +user+ or current user is allowed to view the version
52 52 def visible?(user=User.current)
53 53 user.allowed_to?(:view_issues, self.project)
54 54 end
55 55
56 56 # Version files have same visibility as project files
57 57 def attachments_visible?(*args)
58 58 project.present? && project.attachments_visible?(*args)
59 59 end
60 60
61 61 def start_date
62 62 @start_date ||= fixed_issues.minimum('start_date')
63 63 end
64 64
65 65 def due_date
66 66 effective_date
67 67 end
68 68
69 69 def due_date=(arg)
70 70 self.effective_date=(arg)
71 71 end
72 72
73 73 # Returns the total estimated time for this version
74 74 # (sum of leaves estimated_hours)
75 75 def estimated_hours
76 76 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
77 77 end
78 78
79 79 # Returns the total reported time for this version
80 80 def spent_hours
81 81 @spent_hours ||= TimeEntry.sum(:hours, :joins => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
82 82 end
83 83
84 84 def closed?
85 85 status == 'closed'
86 86 end
87 87
88 88 def open?
89 89 status == 'open'
90 90 end
91 91
92 92 # Returns true if the version is completed: due date reached and no open issues
93 93 def completed?
94 94 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
95 95 end
96 96
97 97 def behind_schedule?
98 98 if completed_pourcent == 100
99 99 return false
100 100 elsif due_date && start_date
101 101 done_date = start_date + ((due_date - start_date+1)* completed_pourcent/100).floor
102 102 return done_date <= Date.today
103 103 else
104 104 false # No issues so it's not late
105 105 end
106 106 end
107 107
108 108 # Returns the completion percentage of this version based on the amount of open/closed issues
109 109 # and the time spent on the open issues.
110 110 def completed_pourcent
111 111 if issues_count == 0
112 112 0
113 113 elsif open_issues_count == 0
114 114 100
115 115 else
116 116 issues_progress(false) + issues_progress(true)
117 117 end
118 118 end
119 119
120 120 # Returns the percentage of issues that have been marked as 'closed'.
121 121 def closed_pourcent
122 122 if issues_count == 0
123 123 0
124 124 else
125 125 issues_progress(false)
126 126 end
127 127 end
128 128
129 129 # Returns true if the version is overdue: due date reached and some open issues
130 130 def overdue?
131 131 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
132 132 end
133 133
134 134 # Returns assigned issues count
135 135 def issues_count
136 136 load_issue_counts
137 137 @issue_count
138 138 end
139 139
140 140 # Returns the total amount of open issues for this version.
141 141 def open_issues_count
142 142 load_issue_counts
143 143 @open_issues_count
144 144 end
145 145
146 146 # Returns the total amount of closed issues for this version.
147 147 def closed_issues_count
148 148 load_issue_counts
149 149 @closed_issues_count
150 150 end
151 151
152 152 def wiki_page
153 153 if project.wiki && !wiki_page_title.blank?
154 154 @wiki_page ||= project.wiki.find_page(wiki_page_title)
155 155 end
156 156 @wiki_page
157 157 end
158 158
159 159 def to_s; name end
160 160
161 161 def to_s_with_project
162 162 "#{project} - #{name}"
163 163 end
164 164
165 165 # Versions are sorted by effective_date and "Project Name - Version name"
166 166 # Those with no effective_date are at the end, sorted by "Project Name - Version name"
167 167 def <=>(version)
168 168 if self.effective_date
169 169 if version.effective_date
170 170 if self.effective_date == version.effective_date
171 171 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
172 172 else
173 173 self.effective_date <=> version.effective_date
174 174 end
175 175 else
176 176 -1
177 177 end
178 178 else
179 179 if version.effective_date
180 180 1
181 181 else
182 182 "#{self.project.name} - #{self.name}" <=> "#{version.project.name} - #{version.name}"
183 183 end
184 184 end
185 185 end
186 186
187 187 # Returns the sharings that +user+ can set the version to
188 188 def allowed_sharings(user = User.current)
189 189 VERSION_SHARINGS.select do |s|
190 190 if sharing == s
191 191 true
192 192 else
193 193 case s
194 194 when 'system'
195 195 # Only admin users can set a systemwide sharing
196 196 user.admin?
197 197 when 'hierarchy', 'tree'
198 198 # Only users allowed to manage versions of the root project can
199 199 # set sharing to hierarchy or tree
200 200 project.nil? || user.allowed_to?(:manage_versions, project.root)
201 201 else
202 202 true
203 203 end
204 204 end
205 205 end
206 206 end
207 207
208 208 private
209 209
210 210 def load_issue_counts
211 211 unless @issue_count
212 212 @open_issues_count = 0
213 213 @closed_issues_count = 0
214 214 fixed_issues.count(:all, :group => :status).each do |status, count|
215 215 if status.is_closed?
216 216 @closed_issues_count += count
217 217 else
218 218 @open_issues_count += count
219 219 end
220 220 end
221 221 @issue_count = @open_issues_count + @closed_issues_count
222 222 end
223 223 end
224 224
225 225 # Update the issue's fixed versions. Used if a version's sharing changes.
226 226 def update_issues_from_sharing_change
227 227 if sharing_changed?
228 228 if VERSION_SHARINGS.index(sharing_was).nil? ||
229 229 VERSION_SHARINGS.index(sharing).nil? ||
230 230 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
231 231 Issue.update_versions_from_sharing_change self
232 232 end
233 233 end
234 234 end
235 235
236 236 # Returns the average estimated time of assigned issues
237 237 # or 1 if no issue has an estimated time
238 238 # Used to weigth unestimated issues in progress calculation
239 239 def estimated_average
240 240 if @estimated_average.nil?
241 241 average = fixed_issues.average(:estimated_hours).to_f
242 242 if average == 0
243 243 average = 1
244 244 end
245 245 @estimated_average = average
246 246 end
247 247 @estimated_average
248 248 end
249 249
250 250 # Returns the total progress of open or closed issues. The returned percentage takes into account
251 251 # the amount of estimated time set for this version.
252 252 #
253 253 # Examples:
254 254 # issues_progress(true) => returns the progress percentage for open issues.
255 255 # issues_progress(false) => returns the progress percentage for closed issues.
256 256 def issues_progress(open)
257 257 @issues_progress ||= {}
258 258 @issues_progress[open] ||= begin
259 259 progress = 0
260 260 if issues_count > 0
261 261 ratio = open ? 'done_ratio' : 100
262 262
263 263 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
264 264 :joins => :status,
265 265 :conditions => ["#{IssueStatus.table_name}.is_closed = ?", !open]).to_f
266 266 progress = done / (estimated_average * issues_count)
267 267 end
268 268 progress
269 269 end
270 270 end
271 271 end
@@ -1,234 +1,234
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'diff'
19 19 require 'enumerator'
20 20
21 21 class WikiPage < ActiveRecord::Base
22 22 include Redmine::SafeAttributes
23 23
24 24 belongs_to :wiki
25 25 has_one :content, :class_name => 'WikiContent', :foreign_key => 'page_id', :dependent => :destroy
26 26 acts_as_attachable :delete_permission => :delete_wiki_pages_attachments
27 27 acts_as_tree :dependent => :nullify, :order => 'title'
28 28
29 29 acts_as_watchable
30 30 acts_as_event :title => Proc.new {|o| "#{l(:label_wiki)}: #{o.title}"},
31 31 :description => :text,
32 32 :datetime => :created_on,
33 33 :url => Proc.new {|o| {:controller => 'wiki', :action => 'show', :project_id => o.wiki.project, :id => o.title}}
34 34
35 35 acts_as_searchable :columns => ['title', "#{WikiContent.table_name}.text"],
36 36 :include => [{:wiki => :project}, :content],
37 37 :permission => :view_wiki_pages,
38 38 :project_key => "#{Wiki.table_name}.project_id"
39 39
40 40 attr_accessor :redirect_existing_links
41 41
42 42 validates_presence_of :title
43 43 validates_format_of :title, :with => /^[^,\.\/\?\;\|\s]*$/
44 44 validates_uniqueness_of :title, :scope => :wiki_id, :case_sensitive => false
45 45 validates_associated :content
46 46
47 47 validate :validate_parent_title
48 48 before_destroy :remove_redirects
49 49 before_save :handle_redirects
50 50
51 51 # eager load information about last updates, without loading text
52 named_scope :with_updated_on, {
52 scope :with_updated_on, {
53 53 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on",
54 54 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
55 55 }
56 56
57 57 # Wiki pages that are protected by default
58 58 DEFAULT_PROTECTED_PAGES = %w(sidebar)
59 59
60 60 safe_attributes 'parent_id',
61 61 :if => lambda {|page, user| page.new_record? || user.allowed_to?(:rename_wiki_pages, page.project)}
62 62
63 63 def initialize(attributes=nil, *args)
64 64 super
65 65 if new_record? && DEFAULT_PROTECTED_PAGES.include?(title.to_s.downcase)
66 66 self.protected = true
67 67 end
68 68 end
69 69
70 70 def visible?(user=User.current)
71 71 !user.nil? && user.allowed_to?(:view_wiki_pages, project)
72 72 end
73 73
74 74 def title=(value)
75 75 value = Wiki.titleize(value)
76 76 @previous_title = read_attribute(:title) if @previous_title.blank?
77 77 write_attribute(:title, value)
78 78 end
79 79
80 80 def handle_redirects
81 81 self.title = Wiki.titleize(title)
82 82 # Manage redirects if the title has changed
83 83 if !@previous_title.blank? && (@previous_title != title) && !new_record?
84 84 # Update redirects that point to the old title
85 85 wiki.redirects.find_all_by_redirects_to(@previous_title).each do |r|
86 86 r.redirects_to = title
87 87 r.title == r.redirects_to ? r.destroy : r.save
88 88 end
89 89 # Remove redirects for the new title
90 90 wiki.redirects.find_all_by_title(title).each(&:destroy)
91 91 # Create a redirect to the new title
92 92 wiki.redirects << WikiRedirect.new(:title => @previous_title, :redirects_to => title) unless redirect_existing_links == "0"
93 93 @previous_title = nil
94 94 end
95 95 end
96 96
97 97 def remove_redirects
98 98 # Remove redirects to this page
99 99 wiki.redirects.find_all_by_redirects_to(title).each(&:destroy)
100 100 end
101 101
102 102 def pretty_title
103 103 WikiPage.pretty_title(title)
104 104 end
105 105
106 106 def content_for_version(version=nil)
107 107 result = content.versions.find_by_version(version.to_i) if version
108 108 result ||= content
109 109 result
110 110 end
111 111
112 112 def diff(version_to=nil, version_from=nil)
113 113 version_to = version_to ? version_to.to_i : self.content.version
114 114 version_from = version_from ? version_from.to_i : version_to - 1
115 115 version_to, version_from = version_from, version_to unless version_from < version_to
116 116
117 117 content_to = content.versions.find_by_version(version_to)
118 118 content_from = content.versions.find_by_version(version_from)
119 119
120 120 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
121 121 end
122 122
123 123 def annotate(version=nil)
124 124 version = version ? version.to_i : self.content.version
125 125 c = content.versions.find_by_version(version)
126 126 c ? WikiAnnotate.new(c) : nil
127 127 end
128 128
129 129 def self.pretty_title(str)
130 130 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
131 131 end
132 132
133 133 def project
134 134 wiki.project
135 135 end
136 136
137 137 def text
138 138 content.text if content
139 139 end
140 140
141 141 def updated_on
142 142 unless @updated_on
143 143 if time = read_attribute(:updated_on)
144 144 # content updated_on was eager loaded with the page
145 145 begin
146 146 @updated_on = Time.zone ? Time.zone.parse(time.to_s) : Time.parse(time.to_s)
147 147 rescue
148 148 end
149 149 else
150 150 @updated_on = content && content.updated_on
151 151 end
152 152 end
153 153 @updated_on
154 154 end
155 155
156 156 # Returns true if usr is allowed to edit the page, otherwise false
157 157 def editable_by?(usr)
158 158 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
159 159 end
160 160
161 161 def attachments_deletable?(usr=User.current)
162 162 editable_by?(usr) && super(usr)
163 163 end
164 164
165 165 def parent_title
166 166 @parent_title || (self.parent && self.parent.pretty_title)
167 167 end
168 168
169 169 def parent_title=(t)
170 170 @parent_title = t
171 171 parent_page = t.blank? ? nil : self.wiki.find_page(t)
172 172 self.parent = parent_page
173 173 end
174 174
175 175 protected
176 176
177 177 def validate_parent_title
178 178 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
179 179 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
180 180 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
181 181 end
182 182 end
183 183
184 184 class WikiDiff < Redmine::Helpers::Diff
185 185 attr_reader :content_to, :content_from
186 186
187 187 def initialize(content_to, content_from)
188 188 @content_to = content_to
189 189 @content_from = content_from
190 190 super(content_to.text, content_from.text)
191 191 end
192 192 end
193 193
194 194 class WikiAnnotate
195 195 attr_reader :lines, :content
196 196
197 197 def initialize(content)
198 198 @content = content
199 199 current = content
200 200 current_lines = current.text.split(/\r?\n/)
201 201 @lines = current_lines.collect {|t| [nil, nil, t]}
202 202 positions = []
203 203 current_lines.size.times {|i| positions << i}
204 204 while (current.previous)
205 205 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
206 206 d.each_slice(3) do |s|
207 207 sign, line = s[0], s[1]
208 208 if sign == '+' && positions[line] && positions[line] != -1
209 209 if @lines[positions[line]][0].nil?
210 210 @lines[positions[line]][0] = current.version
211 211 @lines[positions[line]][1] = current.author
212 212 end
213 213 end
214 214 end
215 215 d.each_slice(3) do |s|
216 216 sign, line = s[0], s[1]
217 217 if sign == '-'
218 218 positions.insert(line, -1)
219 219 else
220 220 positions[line] = nil
221 221 end
222 222 end
223 223 positions.compact!
224 224 # Stop if every line is annotated
225 225 break unless @lines.detect { |line| line[0].nil? }
226 226 current = current.previous
227 227 end
228 228 @lines.each { |line|
229 229 line[0] ||= current.version
230 230 # if the last known version is > 1 (eg. history was cleared), we don't know the author
231 231 line[1] ||= current.author if current.version == 1
232 232 }
233 233 end
234 234 end
General Comments 0
You need to be logged in to leave comments. Login now