##// END OF EJS Templates
Rewrites named scopes with ARel queries....
Jean-Philippe Lang -
r10723:7222e4012db5
parent child
Show More
@@ -1,89 +1,90
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class 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_tree :dependent => :nullify
25 25 acts_as_list :scope => '(project_id = #{project_id} AND parent_id #{parent_id ? "= #{parent_id}" : "IS NULL"})'
26 26 acts_as_watchable
27 27
28 28 validates_presence_of :name, :description
29 29 validates_length_of :name, :maximum => 30
30 30 validates_length_of :description, :maximum => 255
31 31 validate :validate_board
32 32
33 scope :visible, lambda {|*args| { :include => :project,
34 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
33 scope :visible, lambda {|*args|
34 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
35 }
35 36
36 37 safe_attributes 'name', 'description', 'parent_id', 'move_to'
37 38
38 39 def visible?(user=User.current)
39 40 !user.nil? && user.allowed_to?(:view_messages, project)
40 41 end
41 42
42 43 def reload(*args)
43 44 @valid_parents = nil
44 45 super
45 46 end
46 47
47 48 def to_s
48 49 name
49 50 end
50 51
51 52 def valid_parents
52 53 @valid_parents ||= project.boards - self_and_descendants
53 54 end
54 55
55 56 def reset_counters!
56 57 self.class.reset_counters!(id)
57 58 end
58 59
59 60 # Updates topics_count, messages_count and last_message_id attributes for +board_id+
60 61 def self.reset_counters!(board_id)
61 62 board_id = board_id.to_i
62 63 update_all("topics_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id} AND parent_id IS NULL)," +
63 64 " messages_count = (SELECT COUNT(*) FROM #{Message.table_name} WHERE board_id=#{board_id})," +
64 65 " last_message_id = (SELECT MAX(id) FROM #{Message.table_name} WHERE board_id=#{board_id})",
65 66 ["id = ?", board_id])
66 67 end
67 68
68 69 def self.board_tree(boards, parent_id=nil, level=0)
69 70 tree = []
70 71 boards.select {|board| board.parent_id == parent_id}.sort_by(&:position).each do |board|
71 72 tree << [board, level]
72 73 tree += board_tree(boards, board.id, level+1)
73 74 end
74 75 if block_given?
75 76 tree.each do |board, level|
76 77 yield board, level
77 78 end
78 79 end
79 80 tree
80 81 end
81 82
82 83 protected
83 84
84 85 def validate_board
85 86 if parent_id && parent_id_changed?
86 87 errors.add(:parent_id, :invalid) unless valid_parents.include?(parent)
87 88 end
88 89 end
89 90 end
@@ -1,280 +1,280
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'iconv'
19 19
20 20 class Changeset < ActiveRecord::Base
21 21 belongs_to :repository
22 22 belongs_to :user
23 23 has_many :filechanges, :class_name => 'Change', :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 scope :visible,
53 lambda {|*args| { :include => {:repository => :project},
54 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args) } }
52 scope :visible, lambda {|*args|
53 includes(:repository => :project).where(Project.allowed_to_condition(args.shift || User.current, :view_changesets, *args))
54 }
55 55
56 56 after_create :scan_for_issues
57 57 before_create :before_create_cs
58 58
59 59 def revision=(r)
60 60 write_attribute :revision, (r.nil? ? nil : r.to_s)
61 61 end
62 62
63 63 # Returns the identifier of this changeset; depending on repository backends
64 64 def identifier
65 65 if repository.class.respond_to? :changeset_identifier
66 66 repository.class.changeset_identifier self
67 67 else
68 68 revision.to_s
69 69 end
70 70 end
71 71
72 72 def committed_on=(date)
73 73 self.commit_date = date
74 74 super
75 75 end
76 76
77 77 # Returns the readable identifier
78 78 def format_identifier
79 79 if repository.class.respond_to? :format_changeset_identifier
80 80 repository.class.format_changeset_identifier self
81 81 else
82 82 identifier
83 83 end
84 84 end
85 85
86 86 def project
87 87 repository.project
88 88 end
89 89
90 90 def author
91 91 user || committer.to_s.split('<').first
92 92 end
93 93
94 94 def before_create_cs
95 95 self.committer = self.class.to_utf8(self.committer, repository.repo_log_encoding)
96 96 self.comments = self.class.normalize_comments(
97 97 self.comments, repository.repo_log_encoding)
98 98 self.user = repository.find_committer_user(self.committer)
99 99 end
100 100
101 101 def scan_for_issues
102 102 scan_comment_for_issue_ids
103 103 end
104 104
105 105 TIMELOG_RE = /
106 106 (
107 107 ((\d+)(h|hours?))((\d+)(m|min)?)?
108 108 |
109 109 ((\d+)(h|hours?|m|min))
110 110 |
111 111 (\d+):(\d+)
112 112 |
113 113 (\d+([\.,]\d+)?)h?
114 114 )
115 115 /x
116 116
117 117 def scan_comment_for_issue_ids
118 118 return if comments.blank?
119 119 # keywords used to reference issues
120 120 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
121 121 ref_keywords_any = ref_keywords.delete('*')
122 122 # keywords used to fix issues
123 123 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
124 124
125 125 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
126 126
127 127 referenced_issues = []
128 128
129 129 comments.scan(/([\s\(\[,-]|^)((#{kw_regexp})[\s:]+)?(#\d+(\s+@#{TIMELOG_RE})?([\s,;&]+#\d+(\s+@#{TIMELOG_RE})?)*)(?=[[:punct:]]|\s|<|$)/i) do |match|
130 130 action, refs = match[2], match[3]
131 131 next unless action.present? || ref_keywords_any
132 132
133 133 refs.scan(/#(\d+)(\s+@#{TIMELOG_RE})?/).each do |m|
134 134 issue, hours = find_referenced_issue_by_id(m[0].to_i), m[2]
135 135 if issue
136 136 referenced_issues << issue
137 137 fix_issue(issue) if fix_keywords.include?(action.to_s.downcase)
138 138 log_time(issue, hours) if hours && Setting.commit_logtime_enabled?
139 139 end
140 140 end
141 141 end
142 142
143 143 referenced_issues.uniq!
144 144 self.issues = referenced_issues unless referenced_issues.empty?
145 145 end
146 146
147 147 def short_comments
148 148 @short_comments || split_comments.first
149 149 end
150 150
151 151 def long_comments
152 152 @long_comments || split_comments.last
153 153 end
154 154
155 155 def text_tag(ref_project=nil)
156 156 tag = if scmid?
157 157 "commit:#{scmid}"
158 158 else
159 159 "r#{revision}"
160 160 end
161 161 if repository && repository.identifier.present?
162 162 tag = "#{repository.identifier}|#{tag}"
163 163 end
164 164 if ref_project && project && ref_project != project
165 165 tag = "#{project.identifier}:#{tag}"
166 166 end
167 167 tag
168 168 end
169 169
170 170 # Returns the title used for the changeset in the activity/search results
171 171 def title
172 172 repo = (repository && repository.identifier.present?) ? " (#{repository.identifier})" : ''
173 173 comm = short_comments.blank? ? '' : (': ' + short_comments)
174 174 "#{l(:label_revision)} #{format_identifier}#{repo}#{comm}"
175 175 end
176 176
177 177 # Returns the previous changeset
178 178 def previous
179 179 @previous ||= Changeset.where(["id < ? AND repository_id = ?", id, repository_id]).order('id DESC').first
180 180 end
181 181
182 182 # Returns the next changeset
183 183 def next
184 184 @next ||= Changeset.where(["id > ? AND repository_id = ?", id, repository_id]).order('id ASC').first
185 185 end
186 186
187 187 # Creates a new Change from it's common parameters
188 188 def create_change(change)
189 189 Change.create(:changeset => self,
190 190 :action => change[:action],
191 191 :path => change[:path],
192 192 :from_path => change[:from_path],
193 193 :from_revision => change[:from_revision])
194 194 end
195 195
196 196 # Finds an issue that can be referenced by the commit message
197 197 def find_referenced_issue_by_id(id)
198 198 return nil if id.blank?
199 199 issue = Issue.find_by_id(id.to_i, :include => :project)
200 200 if Setting.commit_cross_project_ref?
201 201 # all issues can be referenced/fixed
202 202 elsif issue
203 203 # issue that belong to the repository project, a subproject or a parent project only
204 204 unless issue.project &&
205 205 (project == issue.project || project.is_ancestor_of?(issue.project) ||
206 206 project.is_descendant_of?(issue.project))
207 207 issue = nil
208 208 end
209 209 end
210 210 issue
211 211 end
212 212
213 213 private
214 214
215 215 def fix_issue(issue)
216 216 status = IssueStatus.find_by_id(Setting.commit_fix_status_id.to_i)
217 217 if status.nil?
218 218 logger.warn("No status matches commit_fix_status_id setting (#{Setting.commit_fix_status_id})") if logger
219 219 return issue
220 220 end
221 221
222 222 # the issue may have been updated by the closure of another one (eg. duplicate)
223 223 issue.reload
224 224 # don't change the status is the issue is closed
225 225 return if issue.status && issue.status.is_closed?
226 226
227 227 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, text_tag(issue.project)))
228 228 issue.status = status
229 229 unless Setting.commit_fix_done_ratio.blank?
230 230 issue.done_ratio = Setting.commit_fix_done_ratio.to_i
231 231 end
232 232 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
233 233 { :changeset => self, :issue => issue })
234 234 unless issue.save
235 235 logger.warn("Issue ##{issue.id} could not be saved by changeset #{id}: #{issue.errors.full_messages}") if logger
236 236 end
237 237 issue
238 238 end
239 239
240 240 def log_time(issue, hours)
241 241 time_entry = TimeEntry.new(
242 242 :user => user,
243 243 :hours => hours,
244 244 :issue => issue,
245 245 :spent_on => commit_date,
246 246 :comments => l(:text_time_logged_by_changeset, :value => text_tag(issue.project),
247 247 :locale => Setting.default_language)
248 248 )
249 249 time_entry.activity = log_time_activity unless log_time_activity.nil?
250 250
251 251 unless time_entry.save
252 252 logger.warn("TimeEntry could not be created by changeset #{id}: #{time_entry.errors.full_messages}") if logger
253 253 end
254 254 time_entry
255 255 end
256 256
257 257 def log_time_activity
258 258 if Setting.commit_logtime_activity_id.to_i > 0
259 259 TimeEntryActivity.find_by_id(Setting.commit_logtime_activity_id.to_i)
260 260 end
261 261 end
262 262
263 263 def split_comments
264 264 comments =~ /\A(.+?)\r?\n(.*)$/m
265 265 @short_comments = $1 || comments
266 266 @long_comments = $2.to_s.strip
267 267 return @short_comments, @long_comments
268 268 end
269 269
270 270 public
271 271
272 272 # Strips and reencodes a commit log before insertion into the database
273 273 def self.normalize_comments(str, encoding)
274 274 Changeset.to_utf8(str.to_s.strip, encoding)
275 275 end
276 276
277 277 def self.to_utf8(str, encoding)
278 278 Redmine::CodesetUtil.to_utf8(str, encoding)
279 279 end
280 280 end
@@ -1,56 +1,57
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class 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| o.attachments.reorder("#{Attachment.table_name}.created_on ASC").first.try(:author) },
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 scope :visible, lambda {|*args| { :include => :project,
34 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_documents, *args) } }
33 scope :visible, lambda {|*args|
34 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_documents, *args))
35 }
35 36
36 37 safe_attributes 'category_id', 'title', 'description'
37 38
38 39 def visible?(user=User.current)
39 40 !user.nil? && user.allowed_to?(:view_documents, project)
40 41 end
41 42
42 43 def initialize(attributes=nil, *args)
43 44 super
44 45 if new_record?
45 46 self.category ||= DocumentCategory.default
46 47 end
47 48 end
48 49
49 50 def updated_on
50 51 unless @updated_on
51 52 a = attachments.last
52 53 @updated_on = (a && a.created_on) || created_on
53 54 end
54 55 @updated_on
55 56 end
56 57 end
@@ -1,1395 +1,1396
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Issue < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::Utils::DateCalculation
21 21
22 22 belongs_to :project
23 23 belongs_to :tracker
24 24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
25 25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 26 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
27 27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
28 28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
29 29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
30 30
31 31 has_many :journals, :as => :journalized, :dependent => :destroy
32 32 has_many :visible_journals,
33 33 :class_name => 'Journal',
34 34 :as => :journalized,
35 35 :conditions => Proc.new {
36 36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 37 },
38 38 :readonly => true
39 39
40 40 has_many :time_entries, :dependent => :delete_all
41 41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
47 47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
48 48 acts_as_customizable
49 49 acts_as_watchable
50 50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
51 51 :include => [:project, :visible_journals],
52 52 # sort by id so that limited eager loading doesn't break with postgresql
53 53 :order_column => "#{table_name}.id"
54 54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
55 55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
56 56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
57 57
58 58 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
59 59 :author_key => :author_id
60 60
61 61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
62 62
63 63 attr_reader :current_journal
64 64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65 65
66 66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67 67
68 68 validates_length_of :subject, :maximum => 255
69 69 validates_inclusion_of :done_ratio, :in => 0..100
70 70 validates_numericality_of :estimated_hours, :allow_nil => true
71 71 validate :validate_issue, :validate_required_fields
72 72
73 scope :visible,
74 lambda {|*args| { :include => :project,
75 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
73 scope :visible, lambda {|*args|
74 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
75 }
76 76
77 77 scope :open, lambda {|*args|
78 78 is_closed = args.size > 0 ? !args.first : false
79 {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status}
79 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
80 80 }
81 81
82 scope :recently_updated, lambda { { :order => "#{Issue.table_name}.updated_on DESC" } }
83 scope :on_active_project, lambda { { :include => [:status, :project, :tracker],
84 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"] } }
82 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
83 scope :on_active_project, lambda {
84 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
85 }
85 86
86 87 before_create :default_assign
87 88 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
88 89 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
89 90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 91 # Should be after_create but would be called before previous after_save callbacks
91 92 after_save :after_create_from_copy
92 93 after_destroy :update_parent_attributes
93 94
94 95 # Returns a SQL conditions string used to find all issues visible by the specified user
95 96 def self.visible_condition(user, options={})
96 97 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
97 98 if user.logged?
98 99 case role.issues_visibility
99 100 when 'all'
100 101 nil
101 102 when 'default'
102 103 user_ids = [user.id] + user.groups.map(&:id)
103 104 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
104 105 when 'own'
105 106 user_ids = [user.id] + user.groups.map(&:id)
106 107 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
107 108 else
108 109 '1=0'
109 110 end
110 111 else
111 112 "(#{table_name}.is_private = #{connection.quoted_false})"
112 113 end
113 114 end
114 115 end
115 116
116 117 # Returns true if usr or current user is allowed to view the issue
117 118 def visible?(usr=nil)
118 119 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
119 120 if user.logged?
120 121 case role.issues_visibility
121 122 when 'all'
122 123 true
123 124 when 'default'
124 125 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
125 126 when 'own'
126 127 self.author == user || user.is_or_belongs_to?(assigned_to)
127 128 else
128 129 false
129 130 end
130 131 else
131 132 !self.is_private?
132 133 end
133 134 end
134 135 end
135 136
136 137 def initialize(attributes=nil, *args)
137 138 super
138 139 if new_record?
139 140 # set default values for new records only
140 141 self.status ||= IssueStatus.default
141 142 self.priority ||= IssuePriority.default
142 143 self.watcher_user_ids = []
143 144 end
144 145 end
145 146
146 147 # AR#Persistence#destroy would raise and RecordNotFound exception
147 148 # if the issue was already deleted or updated (non matching lock_version).
148 149 # This is a problem when bulk deleting issues or deleting a project
149 150 # (because an issue may already be deleted if its parent was deleted
150 151 # first).
151 152 # The issue is reloaded by the nested_set before being deleted so
152 153 # the lock_version condition should not be an issue but we handle it.
153 154 def destroy
154 155 super
155 156 rescue ActiveRecord::RecordNotFound
156 157 # Stale or already deleted
157 158 begin
158 159 reload
159 160 rescue ActiveRecord::RecordNotFound
160 161 # The issue was actually already deleted
161 162 @destroyed = true
162 163 return freeze
163 164 end
164 165 # The issue was stale, retry to destroy
165 166 super
166 167 end
167 168
168 169 def reload(*args)
169 170 @workflow_rule_by_attribute = nil
170 171 @assignable_versions = nil
171 172 super
172 173 end
173 174
174 175 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
175 176 def available_custom_fields
176 177 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
177 178 end
178 179
179 180 # Copies attributes from another issue, arg can be an id or an Issue
180 181 def copy_from(arg, options={})
181 182 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
182 183 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
183 184 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
184 185 self.status = issue.status
185 186 self.author = User.current
186 187 unless options[:attachments] == false
187 188 self.attachments = issue.attachments.map do |attachement|
188 189 attachement.copy(:container => self)
189 190 end
190 191 end
191 192 @copied_from = issue
192 193 @copy_options = options
193 194 self
194 195 end
195 196
196 197 # Returns an unsaved copy of the issue
197 198 def copy(attributes=nil, copy_options={})
198 199 copy = self.class.new.copy_from(self, copy_options)
199 200 copy.attributes = attributes if attributes
200 201 copy
201 202 end
202 203
203 204 # Returns true if the issue is a copy
204 205 def copy?
205 206 @copied_from.present?
206 207 end
207 208
208 209 # Moves/copies an issue to a new project and tracker
209 210 # Returns the moved/copied issue on success, false on failure
210 211 def move_to_project(new_project, new_tracker=nil, options={})
211 212 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
212 213
213 214 if options[:copy]
214 215 issue = self.copy
215 216 else
216 217 issue = self
217 218 end
218 219
219 220 issue.init_journal(User.current, options[:notes])
220 221
221 222 # Preserve previous behaviour
222 223 # #move_to_project doesn't change tracker automatically
223 224 issue.send :project=, new_project, true
224 225 if new_tracker
225 226 issue.tracker = new_tracker
226 227 end
227 228 # Allow bulk setting of attributes on the issue
228 229 if options[:attributes]
229 230 issue.attributes = options[:attributes]
230 231 end
231 232
232 233 issue.save ? issue : false
233 234 end
234 235
235 236 def status_id=(sid)
236 237 self.status = nil
237 238 result = write_attribute(:status_id, sid)
238 239 @workflow_rule_by_attribute = nil
239 240 result
240 241 end
241 242
242 243 def priority_id=(pid)
243 244 self.priority = nil
244 245 write_attribute(:priority_id, pid)
245 246 end
246 247
247 248 def category_id=(cid)
248 249 self.category = nil
249 250 write_attribute(:category_id, cid)
250 251 end
251 252
252 253 def fixed_version_id=(vid)
253 254 self.fixed_version = nil
254 255 write_attribute(:fixed_version_id, vid)
255 256 end
256 257
257 258 def tracker_id=(tid)
258 259 self.tracker = nil
259 260 result = write_attribute(:tracker_id, tid)
260 261 @custom_field_values = nil
261 262 @workflow_rule_by_attribute = nil
262 263 result
263 264 end
264 265
265 266 def project_id=(project_id)
266 267 if project_id.to_s != self.project_id.to_s
267 268 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
268 269 end
269 270 end
270 271
271 272 def project=(project, keep_tracker=false)
272 273 project_was = self.project
273 274 write_attribute(:project_id, project ? project.id : nil)
274 275 association_instance_set('project', project)
275 276 if project_was && project && project_was != project
276 277 @assignable_versions = nil
277 278
278 279 unless keep_tracker || project.trackers.include?(tracker)
279 280 self.tracker = project.trackers.first
280 281 end
281 282 # Reassign to the category with same name if any
282 283 if category
283 284 self.category = project.issue_categories.find_by_name(category.name)
284 285 end
285 286 # Keep the fixed_version if it's still valid in the new_project
286 287 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
287 288 self.fixed_version = nil
288 289 end
289 290 # Clear the parent task if it's no longer valid
290 291 unless valid_parent_project?
291 292 self.parent_issue_id = nil
292 293 end
293 294 @custom_field_values = nil
294 295 end
295 296 end
296 297
297 298 def description=(arg)
298 299 if arg.is_a?(String)
299 300 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
300 301 end
301 302 write_attribute(:description, arg)
302 303 end
303 304
304 305 # Overrides assign_attributes so that project and tracker get assigned first
305 306 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
306 307 return if new_attributes.nil?
307 308 attrs = new_attributes.dup
308 309 attrs.stringify_keys!
309 310
310 311 %w(project project_id tracker tracker_id).each do |attr|
311 312 if attrs.has_key?(attr)
312 313 send "#{attr}=", attrs.delete(attr)
313 314 end
314 315 end
315 316 send :assign_attributes_without_project_and_tracker_first, attrs, *args
316 317 end
317 318 # Do not redefine alias chain on reload (see #4838)
318 319 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
319 320
320 321 def estimated_hours=(h)
321 322 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
322 323 end
323 324
324 325 safe_attributes 'project_id',
325 326 :if => lambda {|issue, user|
326 327 if issue.new_record?
327 328 issue.copy?
328 329 elsif user.allowed_to?(:move_issues, issue.project)
329 330 projects = Issue.allowed_target_projects_on_move(user)
330 331 projects.include?(issue.project) && projects.size > 1
331 332 end
332 333 }
333 334
334 335 safe_attributes 'tracker_id',
335 336 'status_id',
336 337 'category_id',
337 338 'assigned_to_id',
338 339 'priority_id',
339 340 'fixed_version_id',
340 341 'subject',
341 342 'description',
342 343 'start_date',
343 344 'due_date',
344 345 'done_ratio',
345 346 'estimated_hours',
346 347 'custom_field_values',
347 348 'custom_fields',
348 349 'lock_version',
349 350 'notes',
350 351 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
351 352
352 353 safe_attributes 'status_id',
353 354 'assigned_to_id',
354 355 'fixed_version_id',
355 356 'done_ratio',
356 357 'lock_version',
357 358 'notes',
358 359 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
359 360
360 361 safe_attributes 'notes',
361 362 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
362 363
363 364 safe_attributes 'private_notes',
364 365 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
365 366
366 367 safe_attributes 'watcher_user_ids',
367 368 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
368 369
369 370 safe_attributes 'is_private',
370 371 :if => lambda {|issue, user|
371 372 user.allowed_to?(:set_issues_private, issue.project) ||
372 373 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
373 374 }
374 375
375 376 safe_attributes 'parent_issue_id',
376 377 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
377 378 user.allowed_to?(:manage_subtasks, issue.project)}
378 379
379 380 def safe_attribute_names(user=nil)
380 381 names = super
381 382 names -= disabled_core_fields
382 383 names -= read_only_attribute_names(user)
383 384 names
384 385 end
385 386
386 387 # Safely sets attributes
387 388 # Should be called from controllers instead of #attributes=
388 389 # attr_accessible is too rough because we still want things like
389 390 # Issue.new(:project => foo) to work
390 391 def safe_attributes=(attrs, user=User.current)
391 392 return unless attrs.is_a?(Hash)
392 393
393 394 attrs = attrs.dup
394 395
395 396 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
396 397 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
397 398 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
398 399 self.project_id = p
399 400 end
400 401 end
401 402
402 403 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
403 404 self.tracker_id = t
404 405 end
405 406
406 407 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
407 408 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
408 409 self.status_id = s
409 410 end
410 411 end
411 412
412 413 attrs = delete_unsafe_attributes(attrs, user)
413 414 return if attrs.empty?
414 415
415 416 unless leaf?
416 417 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
417 418 end
418 419
419 420 if attrs['parent_issue_id'].present?
420 421 s = attrs['parent_issue_id'].to_s
421 422 unless (m = s.match(%r{\A#?(\d+)\z})) && Issue.visible(user).exists?(m[1])
422 423 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
423 424 end
424 425 end
425 426
426 427 if attrs['custom_field_values'].present?
427 428 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
428 429 end
429 430
430 431 if attrs['custom_fields'].present?
431 432 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
432 433 end
433 434
434 435 # mass-assignment security bypass
435 436 assign_attributes attrs, :without_protection => true
436 437 end
437 438
438 439 def disabled_core_fields
439 440 tracker ? tracker.disabled_core_fields : []
440 441 end
441 442
442 443 # Returns the custom_field_values that can be edited by the given user
443 444 def editable_custom_field_values(user=nil)
444 445 custom_field_values.reject do |value|
445 446 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
446 447 end
447 448 end
448 449
449 450 # Returns the names of attributes that are read-only for user or the current user
450 451 # For users with multiple roles, the read-only fields are the intersection of
451 452 # read-only fields of each role
452 453 # The result is an array of strings where sustom fields are represented with their ids
453 454 #
454 455 # Examples:
455 456 # issue.read_only_attribute_names # => ['due_date', '2']
456 457 # issue.read_only_attribute_names(user) # => []
457 458 def read_only_attribute_names(user=nil)
458 459 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
459 460 end
460 461
461 462 # Returns the names of required attributes for user or the current user
462 463 # For users with multiple roles, the required fields are the intersection of
463 464 # required fields of each role
464 465 # The result is an array of strings where sustom fields are represented with their ids
465 466 #
466 467 # Examples:
467 468 # issue.required_attribute_names # => ['due_date', '2']
468 469 # issue.required_attribute_names(user) # => []
469 470 def required_attribute_names(user=nil)
470 471 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
471 472 end
472 473
473 474 # Returns true if the attribute is required for user
474 475 def required_attribute?(name, user=nil)
475 476 required_attribute_names(user).include?(name.to_s)
476 477 end
477 478
478 479 # Returns a hash of the workflow rule by attribute for the given user
479 480 #
480 481 # Examples:
481 482 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
482 483 def workflow_rule_by_attribute(user=nil)
483 484 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
484 485
485 486 user_real = user || User.current
486 487 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
487 488 return {} if roles.empty?
488 489
489 490 result = {}
490 491 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
491 492 if workflow_permissions.any?
492 493 workflow_rules = workflow_permissions.inject({}) do |h, wp|
493 494 h[wp.field_name] ||= []
494 495 h[wp.field_name] << wp.rule
495 496 h
496 497 end
497 498 workflow_rules.each do |attr, rules|
498 499 next if rules.size < roles.size
499 500 uniq_rules = rules.uniq
500 501 if uniq_rules.size == 1
501 502 result[attr] = uniq_rules.first
502 503 else
503 504 result[attr] = 'required'
504 505 end
505 506 end
506 507 end
507 508 @workflow_rule_by_attribute = result if user.nil?
508 509 result
509 510 end
510 511 private :workflow_rule_by_attribute
511 512
512 513 def done_ratio
513 514 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
514 515 status.default_done_ratio
515 516 else
516 517 read_attribute(:done_ratio)
517 518 end
518 519 end
519 520
520 521 def self.use_status_for_done_ratio?
521 522 Setting.issue_done_ratio == 'issue_status'
522 523 end
523 524
524 525 def self.use_field_for_done_ratio?
525 526 Setting.issue_done_ratio == 'issue_field'
526 527 end
527 528
528 529 def validate_issue
529 530 if due_date.nil? && @attributes['due_date'].present?
530 531 errors.add :due_date, :not_a_date
531 532 end
532 533
533 534 if start_date.nil? && @attributes['start_date'].present?
534 535 errors.add :start_date, :not_a_date
535 536 end
536 537
537 538 if due_date && start_date && due_date < start_date
538 539 errors.add :due_date, :greater_than_start_date
539 540 end
540 541
541 542 if start_date && soonest_start && start_date < soonest_start
542 543 errors.add :start_date, :invalid
543 544 end
544 545
545 546 if fixed_version
546 547 if !assignable_versions.include?(fixed_version)
547 548 errors.add :fixed_version_id, :inclusion
548 549 elsif reopened? && fixed_version.closed?
549 550 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
550 551 end
551 552 end
552 553
553 554 # Checks that the issue can not be added/moved to a disabled tracker
554 555 if project && (tracker_id_changed? || project_id_changed?)
555 556 unless project.trackers.include?(tracker)
556 557 errors.add :tracker_id, :inclusion
557 558 end
558 559 end
559 560
560 561 # Checks parent issue assignment
561 562 if @invalid_parent_issue_id.present?
562 563 errors.add :parent_issue_id, :invalid
563 564 elsif @parent_issue
564 565 if !valid_parent_project?(@parent_issue)
565 566 errors.add :parent_issue_id, :invalid
566 567 elsif !new_record?
567 568 # moving an existing issue
568 569 if @parent_issue.root_id != root_id
569 570 # we can always move to another tree
570 571 elsif move_possible?(@parent_issue)
571 572 # move accepted inside tree
572 573 else
573 574 errors.add :parent_issue_id, :invalid
574 575 end
575 576 end
576 577 end
577 578 end
578 579
579 580 # Validates the issue against additional workflow requirements
580 581 def validate_required_fields
581 582 user = new_record? ? author : current_journal.try(:user)
582 583
583 584 required_attribute_names(user).each do |attribute|
584 585 if attribute =~ /^\d+$/
585 586 attribute = attribute.to_i
586 587 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
587 588 if v && v.value.blank?
588 589 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
589 590 end
590 591 else
591 592 if respond_to?(attribute) && send(attribute).blank?
592 593 errors.add attribute, :blank
593 594 end
594 595 end
595 596 end
596 597 end
597 598
598 599 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
599 600 # even if the user turns off the setting later
600 601 def update_done_ratio_from_issue_status
601 602 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
602 603 self.done_ratio = status.default_done_ratio
603 604 end
604 605 end
605 606
606 607 def init_journal(user, notes = "")
607 608 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
608 609 if new_record?
609 610 @current_journal.notify = false
610 611 else
611 612 @attributes_before_change = attributes.dup
612 613 @custom_values_before_change = {}
613 614 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
614 615 end
615 616 @current_journal
616 617 end
617 618
618 619 # Returns the id of the last journal or nil
619 620 def last_journal_id
620 621 if new_record?
621 622 nil
622 623 else
623 624 journals.maximum(:id)
624 625 end
625 626 end
626 627
627 628 # Returns a scope for journals that have an id greater than journal_id
628 629 def journals_after(journal_id)
629 630 scope = journals.reorder("#{Journal.table_name}.id ASC")
630 631 if journal_id.present?
631 632 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
632 633 end
633 634 scope
634 635 end
635 636
636 637 # Return true if the issue is closed, otherwise false
637 638 def closed?
638 639 self.status.is_closed?
639 640 end
640 641
641 642 # Return true if the issue is being reopened
642 643 def reopened?
643 644 if !new_record? && status_id_changed?
644 645 status_was = IssueStatus.find_by_id(status_id_was)
645 646 status_new = IssueStatus.find_by_id(status_id)
646 647 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
647 648 return true
648 649 end
649 650 end
650 651 false
651 652 end
652 653
653 654 # Return true if the issue is being closed
654 655 def closing?
655 656 if !new_record? && status_id_changed?
656 657 status_was = IssueStatus.find_by_id(status_id_was)
657 658 status_new = IssueStatus.find_by_id(status_id)
658 659 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
659 660 return true
660 661 end
661 662 end
662 663 false
663 664 end
664 665
665 666 # Returns true if the issue is overdue
666 667 def overdue?
667 668 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
668 669 end
669 670
670 671 # Is the amount of work done less than it should for the due date
671 672 def behind_schedule?
672 673 return false if start_date.nil? || due_date.nil?
673 674 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
674 675 return done_date <= Date.today
675 676 end
676 677
677 678 # Does this issue have children?
678 679 def children?
679 680 !leaf?
680 681 end
681 682
682 683 # Users the issue can be assigned to
683 684 def assignable_users
684 685 users = project.assignable_users
685 686 users << author if author
686 687 users << assigned_to if assigned_to
687 688 users.uniq.sort
688 689 end
689 690
690 691 # Versions that the issue can be assigned to
691 692 def assignable_versions
692 693 return @assignable_versions if @assignable_versions
693 694
694 695 versions = project.shared_versions.open.all
695 696 if fixed_version
696 697 if fixed_version_id_changed?
697 698 # nothing to do
698 699 elsif project_id_changed?
699 700 if project.shared_versions.include?(fixed_version)
700 701 versions << fixed_version
701 702 end
702 703 else
703 704 versions << fixed_version
704 705 end
705 706 end
706 707 @assignable_versions = versions.uniq.sort
707 708 end
708 709
709 710 # Returns true if this issue is blocked by another issue that is still open
710 711 def blocked?
711 712 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
712 713 end
713 714
714 715 # Returns an array of statuses that user is able to apply
715 716 def new_statuses_allowed_to(user=User.current, include_default=false)
716 717 if new_record? && @copied_from
717 718 [IssueStatus.default, @copied_from.status].compact.uniq.sort
718 719 else
719 720 initial_status = nil
720 721 if new_record?
721 722 initial_status = IssueStatus.default
722 723 elsif status_id_was
723 724 initial_status = IssueStatus.find_by_id(status_id_was)
724 725 end
725 726 initial_status ||= status
726 727
727 728 statuses = initial_status.find_new_statuses_allowed_to(
728 729 user.admin ? Role.all : user.roles_for_project(project),
729 730 tracker,
730 731 author == user,
731 732 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
732 733 )
733 734 statuses << initial_status unless statuses.empty?
734 735 statuses << IssueStatus.default if include_default
735 736 statuses = statuses.compact.uniq.sort
736 737 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
737 738 end
738 739 end
739 740
740 741 def assigned_to_was
741 742 if assigned_to_id_changed? && assigned_to_id_was.present?
742 743 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
743 744 end
744 745 end
745 746
746 747 # Returns the users that should be notified
747 748 def notified_users
748 749 notified = []
749 750 # Author and assignee are always notified unless they have been
750 751 # locked or don't want to be notified
751 752 notified << author if author
752 753 if assigned_to
753 754 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
754 755 end
755 756 if assigned_to_was
756 757 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
757 758 end
758 759 notified = notified.select {|u| u.active? && u.notify_about?(self)}
759 760
760 761 notified += project.notified_users
761 762 notified.uniq!
762 763 # Remove users that can not view the issue
763 764 notified.reject! {|user| !visible?(user)}
764 765 notified
765 766 end
766 767
767 768 # Returns the email addresses that should be notified
768 769 def recipients
769 770 notified_users.collect(&:mail)
770 771 end
771 772
772 773 # Returns the number of hours spent on this issue
773 774 def spent_hours
774 775 @spent_hours ||= time_entries.sum(:hours) || 0
775 776 end
776 777
777 778 # Returns the total number of hours spent on this issue and its descendants
778 779 #
779 780 # Example:
780 781 # spent_hours => 0.0
781 782 # spent_hours => 50.2
782 783 def total_spent_hours
783 784 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
784 785 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
785 786 end
786 787
787 788 def relations
788 789 @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort)
789 790 end
790 791
791 792 # Preloads relations for a collection of issues
792 793 def self.load_relations(issues)
793 794 if issues.any?
794 795 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
795 796 issues.each do |issue|
796 797 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
797 798 end
798 799 end
799 800 end
800 801
801 802 # Preloads visible spent time for a collection of issues
802 803 def self.load_visible_spent_hours(issues, user=User.current)
803 804 if issues.any?
804 805 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
805 806 issues.each do |issue|
806 807 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
807 808 end
808 809 end
809 810 end
810 811
811 812 # Preloads visible relations for a collection of issues
812 813 def self.load_visible_relations(issues, user=User.current)
813 814 if issues.any?
814 815 issue_ids = issues.map(&:id)
815 816 # Relations with issue_from in given issues and visible issue_to
816 817 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
817 818 # Relations with issue_to in given issues and visible issue_from
818 819 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
819 820
820 821 issues.each do |issue|
821 822 relations =
822 823 relations_from.select {|relation| relation.issue_from_id == issue.id} +
823 824 relations_to.select {|relation| relation.issue_to_id == issue.id}
824 825
825 826 issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort)
826 827 end
827 828 end
828 829 end
829 830
830 831 # Finds an issue relation given its id.
831 832 def find_relation(relation_id)
832 833 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
833 834 end
834 835
835 836 def all_dependent_issues(except=[])
836 837 except << self
837 838 dependencies = []
838 839 relations_from.each do |relation|
839 840 if relation.issue_to && !except.include?(relation.issue_to)
840 841 dependencies << relation.issue_to
841 842 dependencies += relation.issue_to.all_dependent_issues(except)
842 843 end
843 844 end
844 845 dependencies
845 846 end
846 847
847 848 # Returns an array of issues that duplicate this one
848 849 def duplicates
849 850 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
850 851 end
851 852
852 853 # Returns the due date or the target due date if any
853 854 # Used on gantt chart
854 855 def due_before
855 856 due_date || (fixed_version ? fixed_version.effective_date : nil)
856 857 end
857 858
858 859 # Returns the time scheduled for this issue.
859 860 #
860 861 # Example:
861 862 # Start Date: 2/26/09, End Date: 3/04/09
862 863 # duration => 6
863 864 def duration
864 865 (start_date && due_date) ? due_date - start_date : 0
865 866 end
866 867
867 868 # Returns the duration in working days
868 869 def working_duration
869 870 (start_date && due_date) ? working_days(start_date, due_date) : 0
870 871 end
871 872
872 873 def soonest_start(reload=false)
873 874 @soonest_start = nil if reload
874 875 @soonest_start ||= (
875 876 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
876 877 ancestors.collect(&:soonest_start)
877 878 ).compact.max
878 879 end
879 880
880 881 # Sets start_date on the given date or the next working day
881 882 # and changes due_date to keep the same working duration.
882 883 def reschedule_on(date)
883 884 wd = working_duration
884 885 date = next_working_date(date)
885 886 self.start_date = date
886 887 self.due_date = add_working_days(date, wd)
887 888 end
888 889
889 890 # Reschedules the issue on the given date or the next working day and saves the record.
890 891 # If the issue is a parent task, this is done by rescheduling its subtasks.
891 892 def reschedule_on!(date)
892 893 return if date.nil?
893 894 if leaf?
894 895 if start_date.nil? || start_date != date
895 896 if start_date && start_date > date
896 897 # Issue can not be moved earlier than its soonest start date
897 898 date = [soonest_start(true), date].compact.max
898 899 end
899 900 reschedule_on(date)
900 901 begin
901 902 save
902 903 rescue ActiveRecord::StaleObjectError
903 904 reload
904 905 reschedule_on(date)
905 906 save
906 907 end
907 908 end
908 909 else
909 910 leaves.each do |leaf|
910 911 if leaf.start_date
911 912 # Only move subtask if it starts at the same date as the parent
912 913 # or if it starts before the given date
913 914 if start_date == leaf.start_date || date > leaf.start_date
914 915 leaf.reschedule_on!(date)
915 916 end
916 917 else
917 918 leaf.reschedule_on!(date)
918 919 end
919 920 end
920 921 end
921 922 end
922 923
923 924 def <=>(issue)
924 925 if issue.nil?
925 926 -1
926 927 elsif root_id != issue.root_id
927 928 (root_id || 0) <=> (issue.root_id || 0)
928 929 else
929 930 (lft || 0) <=> (issue.lft || 0)
930 931 end
931 932 end
932 933
933 934 def to_s
934 935 "#{tracker} ##{id}: #{subject}"
935 936 end
936 937
937 938 # Returns a string of css classes that apply to the issue
938 939 def css_classes
939 940 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
940 941 s << ' closed' if closed?
941 942 s << ' overdue' if overdue?
942 943 s << ' child' if child?
943 944 s << ' parent' unless leaf?
944 945 s << ' private' if is_private?
945 946 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
946 947 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
947 948 s
948 949 end
949 950
950 951 # Saves an issue and a time_entry from the parameters
951 952 def save_issue_with_child_records(params, existing_time_entry=nil)
952 953 Issue.transaction do
953 954 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
954 955 @time_entry = existing_time_entry || TimeEntry.new
955 956 @time_entry.project = project
956 957 @time_entry.issue = self
957 958 @time_entry.user = User.current
958 959 @time_entry.spent_on = User.current.today
959 960 @time_entry.attributes = params[:time_entry]
960 961 self.time_entries << @time_entry
961 962 end
962 963
963 964 # TODO: Rename hook
964 965 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
965 966 if save
966 967 # TODO: Rename hook
967 968 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
968 969 else
969 970 raise ActiveRecord::Rollback
970 971 end
971 972 end
972 973 end
973 974
974 975 # Unassigns issues from +version+ if it's no longer shared with issue's project
975 976 def self.update_versions_from_sharing_change(version)
976 977 # Update issues assigned to the version
977 978 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
978 979 end
979 980
980 981 # Unassigns issues from versions that are no longer shared
981 982 # after +project+ was moved
982 983 def self.update_versions_from_hierarchy_change(project)
983 984 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
984 985 # Update issues of the moved projects and issues assigned to a version of a moved project
985 986 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
986 987 end
987 988
988 989 def parent_issue_id=(arg)
989 990 s = arg.to_s.strip.presence
990 991 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
991 992 @parent_issue.id
992 993 else
993 994 @parent_issue = nil
994 995 @invalid_parent_issue_id = arg
995 996 end
996 997 end
997 998
998 999 def parent_issue_id
999 1000 if @invalid_parent_issue_id
1000 1001 @invalid_parent_issue_id
1001 1002 elsif instance_variable_defined? :@parent_issue
1002 1003 @parent_issue.nil? ? nil : @parent_issue.id
1003 1004 else
1004 1005 parent_id
1005 1006 end
1006 1007 end
1007 1008
1008 1009 # Returns true if issue's project is a valid
1009 1010 # parent issue project
1010 1011 def valid_parent_project?(issue=parent)
1011 1012 return true if issue.nil? || issue.project_id == project_id
1012 1013
1013 1014 case Setting.cross_project_subtasks
1014 1015 when 'system'
1015 1016 true
1016 1017 when 'tree'
1017 1018 issue.project.root == project.root
1018 1019 when 'hierarchy'
1019 1020 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1020 1021 when 'descendants'
1021 1022 issue.project.is_or_is_ancestor_of?(project)
1022 1023 else
1023 1024 false
1024 1025 end
1025 1026 end
1026 1027
1027 1028 # Extracted from the ReportsController.
1028 1029 def self.by_tracker(project)
1029 1030 count_and_group_by(:project => project,
1030 1031 :field => 'tracker_id',
1031 1032 :joins => Tracker.table_name)
1032 1033 end
1033 1034
1034 1035 def self.by_version(project)
1035 1036 count_and_group_by(:project => project,
1036 1037 :field => 'fixed_version_id',
1037 1038 :joins => Version.table_name)
1038 1039 end
1039 1040
1040 1041 def self.by_priority(project)
1041 1042 count_and_group_by(:project => project,
1042 1043 :field => 'priority_id',
1043 1044 :joins => IssuePriority.table_name)
1044 1045 end
1045 1046
1046 1047 def self.by_category(project)
1047 1048 count_and_group_by(:project => project,
1048 1049 :field => 'category_id',
1049 1050 :joins => IssueCategory.table_name)
1050 1051 end
1051 1052
1052 1053 def self.by_assigned_to(project)
1053 1054 count_and_group_by(:project => project,
1054 1055 :field => 'assigned_to_id',
1055 1056 :joins => User.table_name)
1056 1057 end
1057 1058
1058 1059 def self.by_author(project)
1059 1060 count_and_group_by(:project => project,
1060 1061 :field => 'author_id',
1061 1062 :joins => User.table_name)
1062 1063 end
1063 1064
1064 1065 def self.by_subproject(project)
1065 1066 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1066 1067 s.is_closed as closed,
1067 1068 #{Issue.table_name}.project_id as project_id,
1068 1069 count(#{Issue.table_name}.id) as total
1069 1070 from
1070 1071 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1071 1072 where
1072 1073 #{Issue.table_name}.status_id=s.id
1073 1074 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1074 1075 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1075 1076 and #{Issue.table_name}.project_id <> #{project.id}
1076 1077 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1077 1078 end
1078 1079 # End ReportsController extraction
1079 1080
1080 1081 # Returns an array of projects that user can assign the issue to
1081 1082 def allowed_target_projects(user=User.current)
1082 1083 if new_record?
1083 1084 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
1084 1085 else
1085 1086 self.class.allowed_target_projects_on_move(user)
1086 1087 end
1087 1088 end
1088 1089
1089 1090 # Returns an array of projects that user can move issues to
1090 1091 def self.allowed_target_projects_on_move(user=User.current)
1091 1092 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
1092 1093 end
1093 1094
1094 1095 private
1095 1096
1096 1097 def after_project_change
1097 1098 # Update project_id on related time entries
1098 1099 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1099 1100
1100 1101 # Delete issue relations
1101 1102 unless Setting.cross_project_issue_relations?
1102 1103 relations_from.clear
1103 1104 relations_to.clear
1104 1105 end
1105 1106
1106 1107 # Move subtasks that were in the same project
1107 1108 children.each do |child|
1108 1109 next unless child.project_id == project_id_was
1109 1110 # Change project and keep project
1110 1111 child.send :project=, project, true
1111 1112 unless child.save
1112 1113 raise ActiveRecord::Rollback
1113 1114 end
1114 1115 end
1115 1116 end
1116 1117
1117 1118 # Callback for after the creation of an issue by copy
1118 1119 # * adds a "copied to" relation with the copied issue
1119 1120 # * copies subtasks from the copied issue
1120 1121 def after_create_from_copy
1121 1122 return unless copy? && !@after_create_from_copy_handled
1122 1123
1123 1124 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1124 1125 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1125 1126 unless relation.save
1126 1127 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1127 1128 end
1128 1129 end
1129 1130
1130 1131 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1131 1132 @copied_from.children.each do |child|
1132 1133 unless child.visible?
1133 1134 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1134 1135 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1135 1136 next
1136 1137 end
1137 1138 copy = Issue.new.copy_from(child, @copy_options)
1138 1139 copy.author = author
1139 1140 copy.project = project
1140 1141 copy.parent_issue_id = id
1141 1142 # Children subtasks are copied recursively
1142 1143 unless copy.save
1143 1144 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1144 1145 end
1145 1146 end
1146 1147 end
1147 1148 @after_create_from_copy_handled = true
1148 1149 end
1149 1150
1150 1151 def update_nested_set_attributes
1151 1152 if root_id.nil?
1152 1153 # issue was just created
1153 1154 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1154 1155 set_default_left_and_right
1155 1156 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1156 1157 if @parent_issue
1157 1158 move_to_child_of(@parent_issue)
1158 1159 end
1159 1160 reload
1160 1161 elsif parent_issue_id != parent_id
1161 1162 former_parent_id = parent_id
1162 1163 # moving an existing issue
1163 1164 if @parent_issue && @parent_issue.root_id == root_id
1164 1165 # inside the same tree
1165 1166 move_to_child_of(@parent_issue)
1166 1167 else
1167 1168 # to another tree
1168 1169 unless root?
1169 1170 move_to_right_of(root)
1170 1171 reload
1171 1172 end
1172 1173 old_root_id = root_id
1173 1174 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1174 1175 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1175 1176 offset = target_maxright + 1 - lft
1176 1177 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1177 1178 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1178 1179 self[left_column_name] = lft + offset
1179 1180 self[right_column_name] = rgt + offset
1180 1181 if @parent_issue
1181 1182 move_to_child_of(@parent_issue)
1182 1183 end
1183 1184 end
1184 1185 reload
1185 1186 # delete invalid relations of all descendants
1186 1187 self_and_descendants.each do |issue|
1187 1188 issue.relations.each do |relation|
1188 1189 relation.destroy unless relation.valid?
1189 1190 end
1190 1191 end
1191 1192 # update former parent
1192 1193 recalculate_attributes_for(former_parent_id) if former_parent_id
1193 1194 end
1194 1195 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1195 1196 end
1196 1197
1197 1198 def update_parent_attributes
1198 1199 recalculate_attributes_for(parent_id) if parent_id
1199 1200 end
1200 1201
1201 1202 def recalculate_attributes_for(issue_id)
1202 1203 if issue_id && p = Issue.find_by_id(issue_id)
1203 1204 # priority = highest priority of children
1204 1205 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1205 1206 p.priority = IssuePriority.find_by_position(priority_position)
1206 1207 end
1207 1208
1208 1209 # start/due dates = lowest/highest dates of children
1209 1210 p.start_date = p.children.minimum(:start_date)
1210 1211 p.due_date = p.children.maximum(:due_date)
1211 1212 if p.start_date && p.due_date && p.due_date < p.start_date
1212 1213 p.start_date, p.due_date = p.due_date, p.start_date
1213 1214 end
1214 1215
1215 1216 # done ratio = weighted average ratio of leaves
1216 1217 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1217 1218 leaves_count = p.leaves.count
1218 1219 if leaves_count > 0
1219 1220 average = p.leaves.average(:estimated_hours).to_f
1220 1221 if average == 0
1221 1222 average = 1
1222 1223 end
1223 1224 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
1224 1225 progress = done / (average * leaves_count)
1225 1226 p.done_ratio = progress.round
1226 1227 end
1227 1228 end
1228 1229
1229 1230 # estimate = sum of leaves estimates
1230 1231 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1231 1232 p.estimated_hours = nil if p.estimated_hours == 0.0
1232 1233
1233 1234 # ancestors will be recursively updated
1234 1235 p.save(:validate => false)
1235 1236 end
1236 1237 end
1237 1238
1238 1239 # Update issues so their versions are not pointing to a
1239 1240 # fixed_version that is not shared with the issue's project
1240 1241 def self.update_versions(conditions=nil)
1241 1242 # Only need to update issues with a fixed_version from
1242 1243 # a different project and that is not systemwide shared
1243 1244 Issue.scoped(:conditions => conditions).all(
1244 1245 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1245 1246 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1246 1247 " AND #{Version.table_name}.sharing <> 'system'",
1247 1248 :include => [:project, :fixed_version]
1248 1249 ).each do |issue|
1249 1250 next if issue.project.nil? || issue.fixed_version.nil?
1250 1251 unless issue.project.shared_versions.include?(issue.fixed_version)
1251 1252 issue.init_journal(User.current)
1252 1253 issue.fixed_version = nil
1253 1254 issue.save
1254 1255 end
1255 1256 end
1256 1257 end
1257 1258
1258 1259 # Callback on file attachment
1259 1260 def attachment_added(obj)
1260 1261 if @current_journal && !obj.new_record?
1261 1262 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1262 1263 end
1263 1264 end
1264 1265
1265 1266 # Callback on attachment deletion
1266 1267 def attachment_removed(obj)
1267 1268 if @current_journal && !obj.new_record?
1268 1269 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1269 1270 @current_journal.save
1270 1271 end
1271 1272 end
1272 1273
1273 1274 # Default assignment based on category
1274 1275 def default_assign
1275 1276 if assigned_to.nil? && category && category.assigned_to
1276 1277 self.assigned_to = category.assigned_to
1277 1278 end
1278 1279 end
1279 1280
1280 1281 # Updates start/due dates of following issues
1281 1282 def reschedule_following_issues
1282 1283 if start_date_changed? || due_date_changed?
1283 1284 relations_from.each do |relation|
1284 1285 relation.set_issue_to_dates
1285 1286 end
1286 1287 end
1287 1288 end
1288 1289
1289 1290 # Closes duplicates if the issue is being closed
1290 1291 def close_duplicates
1291 1292 if closing?
1292 1293 duplicates.each do |duplicate|
1293 1294 # Reload is need in case the duplicate was updated by a previous duplicate
1294 1295 duplicate.reload
1295 1296 # Don't re-close it if it's already closed
1296 1297 next if duplicate.closed?
1297 1298 # Same user and notes
1298 1299 if @current_journal
1299 1300 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1300 1301 end
1301 1302 duplicate.update_attribute :status, self.status
1302 1303 end
1303 1304 end
1304 1305 end
1305 1306
1306 1307 # Make sure updated_on is updated when adding a note
1307 1308 def force_updated_on_change
1308 1309 if @current_journal
1309 1310 self.updated_on = current_time_from_proper_timezone
1310 1311 end
1311 1312 end
1312 1313
1313 1314 # Saves the changes in a Journal
1314 1315 # Called after_save
1315 1316 def create_journal
1316 1317 if @current_journal
1317 1318 # attributes changes
1318 1319 if @attributes_before_change
1319 1320 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1320 1321 before = @attributes_before_change[c]
1321 1322 after = send(c)
1322 1323 next if before == after || (before.blank? && after.blank?)
1323 1324 @current_journal.details << JournalDetail.new(:property => 'attr',
1324 1325 :prop_key => c,
1325 1326 :old_value => before,
1326 1327 :value => after)
1327 1328 }
1328 1329 end
1329 1330 if @custom_values_before_change
1330 1331 # custom fields changes
1331 1332 custom_field_values.each {|c|
1332 1333 before = @custom_values_before_change[c.custom_field_id]
1333 1334 after = c.value
1334 1335 next if before == after || (before.blank? && after.blank?)
1335 1336
1336 1337 if before.is_a?(Array) || after.is_a?(Array)
1337 1338 before = [before] unless before.is_a?(Array)
1338 1339 after = [after] unless after.is_a?(Array)
1339 1340
1340 1341 # values removed
1341 1342 (before - after).reject(&:blank?).each do |value|
1342 1343 @current_journal.details << JournalDetail.new(:property => 'cf',
1343 1344 :prop_key => c.custom_field_id,
1344 1345 :old_value => value,
1345 1346 :value => nil)
1346 1347 end
1347 1348 # values added
1348 1349 (after - before).reject(&:blank?).each do |value|
1349 1350 @current_journal.details << JournalDetail.new(:property => 'cf',
1350 1351 :prop_key => c.custom_field_id,
1351 1352 :old_value => nil,
1352 1353 :value => value)
1353 1354 end
1354 1355 else
1355 1356 @current_journal.details << JournalDetail.new(:property => 'cf',
1356 1357 :prop_key => c.custom_field_id,
1357 1358 :old_value => before,
1358 1359 :value => after)
1359 1360 end
1360 1361 }
1361 1362 end
1362 1363 @current_journal.save
1363 1364 # reset current journal
1364 1365 init_journal @current_journal.user, @current_journal.notes
1365 1366 end
1366 1367 end
1367 1368
1368 1369 # Query generator for selecting groups of issue counts for a project
1369 1370 # based on specific criteria
1370 1371 #
1371 1372 # Options
1372 1373 # * project - Project to search in.
1373 1374 # * field - String. Issue field to key off of in the grouping.
1374 1375 # * joins - String. The table name to join against.
1375 1376 def self.count_and_group_by(options)
1376 1377 project = options.delete(:project)
1377 1378 select_field = options.delete(:field)
1378 1379 joins = options.delete(:joins)
1379 1380
1380 1381 where = "#{Issue.table_name}.#{select_field}=j.id"
1381 1382
1382 1383 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1383 1384 s.is_closed as closed,
1384 1385 j.id as #{select_field},
1385 1386 count(#{Issue.table_name}.id) as total
1386 1387 from
1387 1388 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1388 1389 where
1389 1390 #{Issue.table_name}.status_id=s.id
1390 1391 and #{where}
1391 1392 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1392 1393 and #{visible_condition(User.current, :project => project)}
1393 1394 group by s.id, s.is_closed, j.id")
1394 1395 end
1395 1396 end
@@ -1,106 +1,107
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class 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, :reset_counters!
45 45 after_update :update_messages_board
46 46 after_destroy :reset_counters!
47 47
48 scope :visible, lambda {|*args| { :include => {:board => :project},
49 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_messages, *args) } }
48 scope :visible, lambda {|*args|
49 includes(:board => :project).where(Project.allowed_to_condition(args.shift || User.current, :view_messages, *args))
50 }
50 51
51 52 safe_attributes 'subject', 'content'
52 53 safe_attributes 'locked', 'sticky', 'board_id',
53 54 :if => lambda {|message, user|
54 55 user.allowed_to?(:edit_messages, message.project)
55 56 }
56 57
57 58 def visible?(user=User.current)
58 59 !user.nil? && user.allowed_to?(:view_messages, project)
59 60 end
60 61
61 62 def cannot_reply_to_locked_topic
62 63 # Can not reply to a locked topic
63 64 errors.add :base, 'Topic is locked' if root.locked? && self != root
64 65 end
65 66
66 67 def update_messages_board
67 68 if board_id_changed?
68 69 Message.update_all("board_id = #{board_id}", ["id = ? OR parent_id = ?", root.id, root.id])
69 70 Board.reset_counters!(board_id_was)
70 71 Board.reset_counters!(board_id)
71 72 end
72 73 end
73 74
74 75 def reset_counters!
75 76 if parent && parent.id
76 77 Message.update_all({:last_reply_id => parent.children.maximum(:id)}, {:id => parent.id})
77 78 end
78 79 board.reset_counters!
79 80 end
80 81
81 82 def sticky=(arg)
82 83 write_attribute :sticky, (arg == true || arg.to_s == '1' ? 1 : 0)
83 84 end
84 85
85 86 def sticky?
86 87 sticky == 1
87 88 end
88 89
89 90 def project
90 91 board.project
91 92 end
92 93
93 94 def editable_by?(usr)
94 95 usr && usr.logged? && (usr.allowed_to?(:edit_messages, project) || (self.author == usr && usr.allowed_to?(:edit_own_messages, project)))
95 96 end
96 97
97 98 def destroyable_by?(usr)
98 99 usr && usr.logged? && (usr.allowed_to?(:delete_messages, project) || (self.author == usr && usr.allowed_to?(:delete_own_messages, project)))
99 100 end
100 101
101 102 private
102 103
103 104 def add_author_as_watcher
104 105 Watcher.create(:watchable => self.root, :user => author)
105 106 end
106 107 end
@@ -1,63 +1,62
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class 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 scope :visible, lambda {|*args| {
38 :include => :project,
39 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_news, *args)
40 }}
37 scope :visible, lambda {|*args|
38 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_news, *args))
39 }
41 40
42 41 safe_attributes 'title', 'summary', 'description'
43 42
44 43 def visible?(user=User.current)
45 44 !user.nil? && user.allowed_to?(:view_news, project)
46 45 end
47 46
48 47 # Returns true if the news can be commented by user
49 48 def commentable?(user=User.current)
50 49 user.allowed_to?(:comment_news, project)
51 50 end
52 51
53 52 # returns latest news for projects visible by user
54 53 def self.latest(user = User.current, count = 5)
55 54 visible(user).includes([:author, :project]).order("#{News.table_name}.created_on DESC").limit(count).all
56 55 end
57 56
58 57 private
59 58
60 59 def add_author_as_watcher
61 60 Watcher.create(:watchable => self, :user => author)
62 61 end
63 62 end
@@ -1,96 +1,96
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Principal < ActiveRecord::Base
19 19 self.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_ARCHIVED}", :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 scope :active, lambda { { :conditions => "#{Principal.table_name}.status = 1" } }
27 scope :active, lambda { where("#{Principal.table_name}.status = 1") }
28 28
29 29 scope :like, lambda {|q|
30 30 q = q.to_s
31 31 if q.blank?
32 32 where({})
33 33 else
34 34 pattern = "%#{q}%"
35 35 sql = %w(login firstname lastname mail).map {|column| "LOWER(#{table_name}.#{column}) LIKE LOWER(:p)"}.join(" OR ")
36 36 params = {:p => pattern}
37 37 if q =~ /^(.+)\s+(.+)$/
38 38 a, b = "#{$1}%", "#{$2}%"
39 39 sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:a) AND LOWER(#{table_name}.lastname) LIKE LOWER(:b))"
40 40 sql << " OR (LOWER(#{table_name}.firstname) LIKE LOWER(:b) AND LOWER(#{table_name}.lastname) LIKE LOWER(:a))"
41 41 params.merge!(:a => a, :b => b)
42 42 end
43 43 where(sql, params)
44 44 end
45 45 }
46 46
47 47 # Principals that are members of a collection of projects
48 48 scope :member_of, lambda {|projects|
49 49 projects = [projects] unless projects.is_a?(Array)
50 50 if projects.empty?
51 51 where("1=0")
52 52 else
53 53 ids = projects.map(&:id)
54 54 where("#{Principal.table_name}.status = 1 AND #{Principal.table_name}.id IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
55 55 end
56 56 }
57 57 # Principals that are not members of projects
58 58 scope :not_member_of, lambda {|projects|
59 59 projects = [projects] unless projects.is_a?(Array)
60 60 if projects.empty?
61 61 where("1=0")
62 62 else
63 63 ids = projects.map(&:id)
64 64 where("#{Principal.table_name}.id NOT IN (SELECT DISTINCT user_id FROM #{Member.table_name} WHERE project_id IN (?))", ids)
65 65 end
66 66 }
67 67
68 68 before_create :set_default_empty_values
69 69
70 70 def name(formatter = nil)
71 71 to_s
72 72 end
73 73
74 74 def <=>(principal)
75 75 if principal.nil?
76 76 -1
77 77 elsif self.class.name == principal.class.name
78 78 self.to_s.downcase <=> principal.to_s.downcase
79 79 else
80 80 # groups after users
81 81 principal.class.name <=> self.class.name
82 82 end
83 83 end
84 84
85 85 protected
86 86
87 87 # Make sure we don't try to insert NULL values (see #4632)
88 88 def set_default_empty_values
89 89 self.login ||= ''
90 90 self.hashed_password ||= ''
91 91 self.firstname ||= ''
92 92 self.lastname ||= ''
93 93 self.mail ||= ''
94 94 true
95 95 end
96 96 end
@@ -1,967 +1,969
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 # Project statuses
22 22 STATUS_ACTIVE = 1
23 23 STATUS_CLOSED = 5
24 24 STATUS_ARCHIVED = 9
25 25
26 26 # Maximum length for project identifiers
27 27 IDENTIFIER_MAX_LENGTH = 100
28 28
29 29 # Specific overidden Activities
30 30 has_many :time_entry_activities
31 31 has_many :members, :include => [:principal, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
32 32 has_many :memberships, :class_name => 'Member'
33 33 has_many :member_principals, :class_name => 'Member',
34 34 :include => :principal,
35 35 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
36 36 has_many :users, :through => :members
37 37 has_many :principals, :through => :member_principals, :source => :principal
38 38
39 39 has_many :enabled_modules, :dependent => :delete_all
40 40 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
41 41 has_many :issues, :dependent => :destroy, :include => [:status, :tracker]
42 42 has_many :issue_changes, :through => :issues, :source => :journals
43 43 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
44 44 has_many :time_entries, :dependent => :delete_all
45 45 has_many :queries, :dependent => :delete_all
46 46 has_many :documents, :dependent => :destroy
47 47 has_many :news, :dependent => :destroy, :include => :author
48 48 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
49 49 has_many :boards, :dependent => :destroy, :order => "position ASC"
50 50 has_one :repository, :conditions => ["is_default = ?", true]
51 51 has_many :repositories, :dependent => :destroy
52 52 has_many :changesets, :through => :repository
53 53 has_one :wiki, :dependent => :destroy
54 54 # Custom field for the project issues
55 55 has_and_belongs_to_many :issue_custom_fields,
56 56 :class_name => 'IssueCustomField',
57 57 :order => "#{CustomField.table_name}.position",
58 58 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
59 59 :association_foreign_key => 'custom_field_id'
60 60
61 61 acts_as_nested_set :order => 'name', :dependent => :destroy
62 62 acts_as_attachable :view_permission => :view_files,
63 63 :delete_permission => :manage_files
64 64
65 65 acts_as_customizable
66 66 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
67 67 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
68 68 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
69 69 :author => nil
70 70
71 71 attr_protected :status
72 72
73 73 validates_presence_of :name, :identifier
74 74 validates_uniqueness_of :identifier
75 75 validates_associated :repository, :wiki
76 76 validates_length_of :name, :maximum => 255
77 77 validates_length_of :homepage, :maximum => 255
78 78 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
79 79 # donwcase letters, digits, dashes but not digits only
80 80 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-_]*$/, :if => Proc.new { |p| p.identifier_changed? }
81 81 # reserved words
82 82 validates_exclusion_of :identifier, :in => %w( new )
83 83
84 84 after_save :update_position_under_parent, :if => Proc.new {|project| project.name_changed?}
85 85 before_destroy :delete_all_members
86 86
87 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] } }
88 scope :active, lambda { { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}" } }
89 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
90 scope :all_public, lambda { { :conditions => { :is_public => true } } }
91 scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
87 scope :has_module, lambda {|mod|
88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 }
90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 scope :all_public, lambda { where(:is_public => true) }
93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
92 94 scope :allowed_to, lambda {|*args|
93 95 user = User.current
94 96 permission = nil
95 97 if args.first.is_a?(Symbol)
96 98 permission = args.shift
97 99 else
98 100 user = args.shift
99 101 permission = args.shift
100 102 end
101 { :conditions => Project.allowed_to_condition(user, permission, *args) }
103 where(Project.allowed_to_condition(user, permission, *args))
102 104 }
103 105 scope :like, lambda {|arg|
104 106 if arg.blank?
105 {}
107 where(nil)
106 108 else
107 109 pattern = "%#{arg.to_s.strip.downcase}%"
108 {:conditions => ["LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", {:p => pattern}]}
110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
109 111 end
110 112 }
111 113
112 114 def initialize(attributes=nil, *args)
113 115 super
114 116
115 117 initialized = (attributes || {}).stringify_keys
116 118 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
117 119 self.identifier = Project.next_identifier
118 120 end
119 121 if !initialized.key?('is_public')
120 122 self.is_public = Setting.default_projects_public?
121 123 end
122 124 if !initialized.key?('enabled_module_names')
123 125 self.enabled_module_names = Setting.default_projects_modules
124 126 end
125 127 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
126 128 self.trackers = Tracker.sorted.all
127 129 end
128 130 end
129 131
130 132 def identifier=(identifier)
131 133 super unless identifier_frozen?
132 134 end
133 135
134 136 def identifier_frozen?
135 137 errors[:identifier].blank? && !(new_record? || identifier.blank?)
136 138 end
137 139
138 140 # returns latest created projects
139 141 # non public projects will be returned only if user is a member of those
140 142 def self.latest(user=nil, count=5)
141 143 visible(user).limit(count).order("created_on DESC").all
142 144 end
143 145
144 146 # Returns true if the project is visible to +user+ or to the current user.
145 147 def visible?(user=User.current)
146 148 user.allowed_to?(:view_project, self)
147 149 end
148 150
149 151 # Returns a SQL conditions string used to find all projects visible by the specified user.
150 152 #
151 153 # Examples:
152 154 # Project.visible_condition(admin) => "projects.status = 1"
153 155 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
154 156 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
155 157 def self.visible_condition(user, options={})
156 158 allowed_to_condition(user, :view_project, options)
157 159 end
158 160
159 161 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
160 162 #
161 163 # Valid options:
162 164 # * :project => limit the condition to project
163 165 # * :with_subprojects => limit the condition to project and its subprojects
164 166 # * :member => limit the condition to the user projects
165 167 def self.allowed_to_condition(user, permission, options={})
166 168 perm = Redmine::AccessControl.permission(permission)
167 169 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
168 170 if perm && perm.project_module
169 171 # If the permission belongs to a project module, make sure the module is enabled
170 172 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
171 173 end
172 174 if options[:project]
173 175 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
174 176 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
175 177 base_statement = "(#{project_statement}) AND (#{base_statement})"
176 178 end
177 179
178 180 if user.admin?
179 181 base_statement
180 182 else
181 183 statement_by_role = {}
182 184 unless options[:member]
183 185 role = user.logged? ? Role.non_member : Role.anonymous
184 186 if role.allowed_to?(permission)
185 187 statement_by_role[role] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
186 188 end
187 189 end
188 190 if user.logged?
189 191 user.projects_by_role.each do |role, projects|
190 192 if role.allowed_to?(permission) && projects.any?
191 193 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
192 194 end
193 195 end
194 196 end
195 197 if statement_by_role.empty?
196 198 "1=0"
197 199 else
198 200 if block_given?
199 201 statement_by_role.each do |role, statement|
200 202 if s = yield(role, user)
201 203 statement_by_role[role] = "(#{statement} AND (#{s}))"
202 204 end
203 205 end
204 206 end
205 207 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
206 208 end
207 209 end
208 210 end
209 211
210 212 # Returns the Systemwide and project specific activities
211 213 def activities(include_inactive=false)
212 214 if include_inactive
213 215 return all_activities
214 216 else
215 217 return active_activities
216 218 end
217 219 end
218 220
219 221 # Will create a new Project specific Activity or update an existing one
220 222 #
221 223 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
222 224 # does not successfully save.
223 225 def update_or_create_time_entry_activity(id, activity_hash)
224 226 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
225 227 self.create_time_entry_activity_if_needed(activity_hash)
226 228 else
227 229 activity = project.time_entry_activities.find_by_id(id.to_i)
228 230 activity.update_attributes(activity_hash) if activity
229 231 end
230 232 end
231 233
232 234 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
233 235 #
234 236 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
235 237 # does not successfully save.
236 238 def create_time_entry_activity_if_needed(activity)
237 239 if activity['parent_id']
238 240
239 241 parent_activity = TimeEntryActivity.find(activity['parent_id'])
240 242 activity['name'] = parent_activity.name
241 243 activity['position'] = parent_activity.position
242 244
243 245 if Enumeration.overridding_change?(activity, parent_activity)
244 246 project_activity = self.time_entry_activities.create(activity)
245 247
246 248 if project_activity.new_record?
247 249 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
248 250 else
249 251 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
250 252 end
251 253 end
252 254 end
253 255 end
254 256
255 257 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
256 258 #
257 259 # Examples:
258 260 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
259 261 # project.project_condition(false) => "projects.id = 1"
260 262 def project_condition(with_subprojects)
261 263 cond = "#{Project.table_name}.id = #{id}"
262 264 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
263 265 cond
264 266 end
265 267
266 268 def self.find(*args)
267 269 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
268 270 project = find_by_identifier(*args)
269 271 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
270 272 project
271 273 else
272 274 super
273 275 end
274 276 end
275 277
276 278 def self.find_by_param(*args)
277 279 self.find(*args)
278 280 end
279 281
280 282 def reload(*args)
281 283 @shared_versions = nil
282 284 @rolled_up_versions = nil
283 285 @rolled_up_trackers = nil
284 286 @all_issue_custom_fields = nil
285 287 @all_time_entry_custom_fields = nil
286 288 @to_param = nil
287 289 @allowed_parents = nil
288 290 @allowed_permissions = nil
289 291 @actions_allowed = nil
290 292 super
291 293 end
292 294
293 295 def to_param
294 296 # id is used for projects with a numeric identifier (compatibility)
295 297 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
296 298 end
297 299
298 300 def active?
299 301 self.status == STATUS_ACTIVE
300 302 end
301 303
302 304 def archived?
303 305 self.status == STATUS_ARCHIVED
304 306 end
305 307
306 308 # Archives the project and its descendants
307 309 def archive
308 310 # Check that there is no issue of a non descendant project that is assigned
309 311 # to one of the project or descendant versions
310 312 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
311 313 if v_ids.any? &&
312 314 Issue.
313 315 includes(:project).
314 316 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
315 317 where("#{Issue.table_name}.fixed_version_id IN (?)", v_ids).
316 318 exists?
317 319 return false
318 320 end
319 321 Project.transaction do
320 322 archive!
321 323 end
322 324 true
323 325 end
324 326
325 327 # Unarchives the project
326 328 # All its ancestors must be active
327 329 def unarchive
328 330 return false if ancestors.detect {|a| !a.active?}
329 331 update_attribute :status, STATUS_ACTIVE
330 332 end
331 333
332 334 def close
333 335 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
334 336 end
335 337
336 338 def reopen
337 339 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
338 340 end
339 341
340 342 # Returns an array of projects the project can be moved to
341 343 # by the current user
342 344 def allowed_parents
343 345 return @allowed_parents if @allowed_parents
344 346 @allowed_parents = Project.where(Project.allowed_to_condition(User.current, :add_subprojects)).all
345 347 @allowed_parents = @allowed_parents - self_and_descendants
346 348 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
347 349 @allowed_parents << nil
348 350 end
349 351 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
350 352 @allowed_parents << parent
351 353 end
352 354 @allowed_parents
353 355 end
354 356
355 357 # Sets the parent of the project with authorization check
356 358 def set_allowed_parent!(p)
357 359 unless p.nil? || p.is_a?(Project)
358 360 if p.to_s.blank?
359 361 p = nil
360 362 else
361 363 p = Project.find_by_id(p)
362 364 return false unless p
363 365 end
364 366 end
365 367 if p.nil?
366 368 if !new_record? && allowed_parents.empty?
367 369 return false
368 370 end
369 371 elsif !allowed_parents.include?(p)
370 372 return false
371 373 end
372 374 set_parent!(p)
373 375 end
374 376
375 377 # Sets the parent of the project
376 378 # Argument can be either a Project, a String, a Fixnum or nil
377 379 def set_parent!(p)
378 380 unless p.nil? || p.is_a?(Project)
379 381 if p.to_s.blank?
380 382 p = nil
381 383 else
382 384 p = Project.find_by_id(p)
383 385 return false unless p
384 386 end
385 387 end
386 388 if p == parent && !p.nil?
387 389 # Nothing to do
388 390 true
389 391 elsif p.nil? || (p.active? && move_possible?(p))
390 392 set_or_update_position_under(p)
391 393 Issue.update_versions_from_hierarchy_change(self)
392 394 true
393 395 else
394 396 # Can not move to the given target
395 397 false
396 398 end
397 399 end
398 400
399 401 # Recalculates all lft and rgt values based on project names
400 402 # Unlike Project.rebuild!, these values are recalculated even if the tree "looks" valid
401 403 # Used in BuildProjectsTree migration
402 404 def self.rebuild_tree!
403 405 transaction do
404 406 update_all "lft = NULL, rgt = NULL"
405 407 rebuild!(false)
406 408 end
407 409 end
408 410
409 411 # Returns an array of the trackers used by the project and its active sub projects
410 412 def rolled_up_trackers
411 413 @rolled_up_trackers ||=
412 414 Tracker.
413 415 joins(:projects).
414 416 select("DISTINCT #{Tracker.table_name}.*").
415 417 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt).
416 418 sorted.
417 419 all
418 420 end
419 421
420 422 # Closes open and locked project versions that are completed
421 423 def close_completed_versions
422 424 Version.transaction do
423 425 versions.where(:status => %w(open locked)).all.each do |version|
424 426 if version.completed?
425 427 version.update_attribute(:status, 'closed')
426 428 end
427 429 end
428 430 end
429 431 end
430 432
431 433 # Returns a scope of the Versions on subprojects
432 434 def rolled_up_versions
433 435 @rolled_up_versions ||=
434 436 Version.scoped(:include => :project,
435 437 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> #{STATUS_ARCHIVED}", lft, rgt])
436 438 end
437 439
438 440 # Returns a scope of the Versions used by the project
439 441 def shared_versions
440 442 if new_record?
441 443 Version.scoped(:include => :project,
442 444 :conditions => "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND #{Version.table_name}.sharing = 'system'")
443 445 else
444 446 @shared_versions ||= begin
445 447 r = root? ? self : root
446 448 Version.scoped(:include => :project,
447 449 :conditions => "#{Project.table_name}.id = #{id}" +
448 450 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
449 451 " #{Version.table_name}.sharing = 'system'" +
450 452 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
451 453 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
452 454 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
453 455 "))")
454 456 end
455 457 end
456 458 end
457 459
458 460 # Returns a hash of project users grouped by role
459 461 def users_by_role
460 462 members.includes(:user, :roles).all.inject({}) do |h, m|
461 463 m.roles.each do |r|
462 464 h[r] ||= []
463 465 h[r] << m.user
464 466 end
465 467 h
466 468 end
467 469 end
468 470
469 471 # Deletes all project's members
470 472 def delete_all_members
471 473 me, mr = Member.table_name, MemberRole.table_name
472 474 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
473 475 Member.delete_all(['project_id = ?', id])
474 476 end
475 477
476 478 # Users/groups issues can be assigned to
477 479 def assignable_users
478 480 assignable = Setting.issue_group_assignment? ? member_principals : members
479 481 assignable.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.principal}.sort
480 482 end
481 483
482 484 # Returns the mail adresses of users that should be always notified on project events
483 485 def recipients
484 486 notified_users.collect {|user| user.mail}
485 487 end
486 488
487 489 # Returns the users that should be notified on project events
488 490 def notified_users
489 491 # TODO: User part should be extracted to User#notify_about?
490 492 members.select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
491 493 end
492 494
493 495 # Returns an array of all custom fields enabled for project issues
494 496 # (explictly associated custom fields and custom fields enabled for all projects)
495 497 def all_issue_custom_fields
496 498 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
497 499 end
498 500
499 501 # Returns an array of all custom fields enabled for project time entries
500 502 # (explictly associated custom fields and custom fields enabled for all projects)
501 503 def all_time_entry_custom_fields
502 504 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
503 505 end
504 506
505 507 def project
506 508 self
507 509 end
508 510
509 511 def <=>(project)
510 512 name.downcase <=> project.name.downcase
511 513 end
512 514
513 515 def to_s
514 516 name
515 517 end
516 518
517 519 # Returns a short description of the projects (first lines)
518 520 def short_description(length = 255)
519 521 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
520 522 end
521 523
522 524 def css_classes
523 525 s = 'project'
524 526 s << ' root' if root?
525 527 s << ' child' if child?
526 528 s << (leaf? ? ' leaf' : ' parent')
527 529 unless active?
528 530 if archived?
529 531 s << ' archived'
530 532 else
531 533 s << ' closed'
532 534 end
533 535 end
534 536 s
535 537 end
536 538
537 539 # The earliest start date of a project, based on it's issues and versions
538 540 def start_date
539 541 [
540 542 issues.minimum('start_date'),
541 543 shared_versions.collect(&:effective_date),
542 544 shared_versions.collect(&:start_date)
543 545 ].flatten.compact.min
544 546 end
545 547
546 548 # The latest due date of an issue or version
547 549 def due_date
548 550 [
549 551 issues.maximum('due_date'),
550 552 shared_versions.collect(&:effective_date),
551 553 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
552 554 ].flatten.compact.max
553 555 end
554 556
555 557 def overdue?
556 558 active? && !due_date.nil? && (due_date < Date.today)
557 559 end
558 560
559 561 # Returns the percent completed for this project, based on the
560 562 # progress on it's versions.
561 563 def completed_percent(options={:include_subprojects => false})
562 564 if options.delete(:include_subprojects)
563 565 total = self_and_descendants.collect(&:completed_percent).sum
564 566
565 567 total / self_and_descendants.count
566 568 else
567 569 if versions.count > 0
568 570 total = versions.collect(&:completed_pourcent).sum
569 571
570 572 total / versions.count
571 573 else
572 574 100
573 575 end
574 576 end
575 577 end
576 578
577 579 # Return true if this project allows to do the specified action.
578 580 # action can be:
579 581 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
580 582 # * a permission Symbol (eg. :edit_project)
581 583 def allows_to?(action)
582 584 if archived?
583 585 # No action allowed on archived projects
584 586 return false
585 587 end
586 588 unless active? || Redmine::AccessControl.read_action?(action)
587 589 # No write action allowed on closed projects
588 590 return false
589 591 end
590 592 # No action allowed on disabled modules
591 593 if action.is_a? Hash
592 594 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
593 595 else
594 596 allowed_permissions.include? action
595 597 end
596 598 end
597 599
598 600 def module_enabled?(module_name)
599 601 module_name = module_name.to_s
600 602 enabled_modules.detect {|m| m.name == module_name}
601 603 end
602 604
603 605 def enabled_module_names=(module_names)
604 606 if module_names && module_names.is_a?(Array)
605 607 module_names = module_names.collect(&:to_s).reject(&:blank?)
606 608 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
607 609 else
608 610 enabled_modules.clear
609 611 end
610 612 end
611 613
612 614 # Returns an array of the enabled modules names
613 615 def enabled_module_names
614 616 enabled_modules.collect(&:name)
615 617 end
616 618
617 619 # Enable a specific module
618 620 #
619 621 # Examples:
620 622 # project.enable_module!(:issue_tracking)
621 623 # project.enable_module!("issue_tracking")
622 624 def enable_module!(name)
623 625 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
624 626 end
625 627
626 628 # Disable a module if it exists
627 629 #
628 630 # Examples:
629 631 # project.disable_module!(:issue_tracking)
630 632 # project.disable_module!("issue_tracking")
631 633 # project.disable_module!(project.enabled_modules.first)
632 634 def disable_module!(target)
633 635 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
634 636 target.destroy unless target.blank?
635 637 end
636 638
637 639 safe_attributes 'name',
638 640 'description',
639 641 'homepage',
640 642 'is_public',
641 643 'identifier',
642 644 'custom_field_values',
643 645 'custom_fields',
644 646 'tracker_ids',
645 647 'issue_custom_field_ids'
646 648
647 649 safe_attributes 'enabled_module_names',
648 650 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
649 651
650 652 # Returns an array of projects that are in this project's hierarchy
651 653 #
652 654 # Example: parents, children, siblings
653 655 def hierarchy
654 656 parents = project.self_and_ancestors || []
655 657 descendants = project.descendants || []
656 658 project_hierarchy = parents | descendants # Set union
657 659 end
658 660
659 661 # Returns an auto-generated project identifier based on the last identifier used
660 662 def self.next_identifier
661 663 p = Project.order('created_on DESC').first
662 664 p.nil? ? nil : p.identifier.to_s.succ
663 665 end
664 666
665 667 # Copies and saves the Project instance based on the +project+.
666 668 # Duplicates the source project's:
667 669 # * Wiki
668 670 # * Versions
669 671 # * Categories
670 672 # * Issues
671 673 # * Members
672 674 # * Queries
673 675 #
674 676 # Accepts an +options+ argument to specify what to copy
675 677 #
676 678 # Examples:
677 679 # project.copy(1) # => copies everything
678 680 # project.copy(1, :only => 'members') # => copies members only
679 681 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
680 682 def copy(project, options={})
681 683 project = project.is_a?(Project) ? project : Project.find(project)
682 684
683 685 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
684 686 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
685 687
686 688 Project.transaction do
687 689 if save
688 690 reload
689 691 to_be_copied.each do |name|
690 692 send "copy_#{name}", project
691 693 end
692 694 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
693 695 save
694 696 end
695 697 end
696 698 end
697 699
698 700 # Returns a new unsaved Project instance with attributes copied from +project+
699 701 def self.copy_from(project)
700 702 project = project.is_a?(Project) ? project : Project.find(project)
701 703 # clear unique attributes
702 704 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
703 705 copy = Project.new(attributes)
704 706 copy.enabled_modules = project.enabled_modules
705 707 copy.trackers = project.trackers
706 708 copy.custom_values = project.custom_values.collect {|v| v.clone}
707 709 copy.issue_custom_fields = project.issue_custom_fields
708 710 copy
709 711 end
710 712
711 713 # Yields the given block for each project with its level in the tree
712 714 def self.project_tree(projects, &block)
713 715 ancestors = []
714 716 projects.sort_by(&:lft).each do |project|
715 717 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
716 718 ancestors.pop
717 719 end
718 720 yield project, ancestors.size
719 721 ancestors << project
720 722 end
721 723 end
722 724
723 725 private
724 726
725 727 # Copies wiki from +project+
726 728 def copy_wiki(project)
727 729 # Check that the source project has a wiki first
728 730 unless project.wiki.nil?
729 731 self.wiki ||= Wiki.new
730 732 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
731 733 wiki_pages_map = {}
732 734 project.wiki.pages.each do |page|
733 735 # Skip pages without content
734 736 next if page.content.nil?
735 737 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
736 738 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
737 739 new_wiki_page.content = new_wiki_content
738 740 wiki.pages << new_wiki_page
739 741 wiki_pages_map[page.id] = new_wiki_page
740 742 end
741 743 wiki.save
742 744 # Reproduce page hierarchy
743 745 project.wiki.pages.each do |page|
744 746 if page.parent_id && wiki_pages_map[page.id]
745 747 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
746 748 wiki_pages_map[page.id].save
747 749 end
748 750 end
749 751 end
750 752 end
751 753
752 754 # Copies versions from +project+
753 755 def copy_versions(project)
754 756 project.versions.each do |version|
755 757 new_version = Version.new
756 758 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
757 759 self.versions << new_version
758 760 end
759 761 end
760 762
761 763 # Copies issue categories from +project+
762 764 def copy_issue_categories(project)
763 765 project.issue_categories.each do |issue_category|
764 766 new_issue_category = IssueCategory.new
765 767 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
766 768 self.issue_categories << new_issue_category
767 769 end
768 770 end
769 771
770 772 # Copies issues from +project+
771 773 def copy_issues(project)
772 774 # Stores the source issue id as a key and the copied issues as the
773 775 # value. Used to map the two togeather for issue relations.
774 776 issues_map = {}
775 777
776 778 # Store status and reopen locked/closed versions
777 779 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
778 780 version_statuses.each do |version, status|
779 781 version.update_attribute :status, 'open'
780 782 end
781 783
782 784 # Get issues sorted by root_id, lft so that parent issues
783 785 # get copied before their children
784 786 project.issues.reorder('root_id, lft').all.each do |issue|
785 787 new_issue = Issue.new
786 788 new_issue.copy_from(issue, :subtasks => false, :link => false)
787 789 new_issue.project = self
788 790 # Reassign fixed_versions by name, since names are unique per project
789 791 if issue.fixed_version && issue.fixed_version.project == project
790 792 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
791 793 end
792 794 # Reassign the category by name, since names are unique per project
793 795 if issue.category
794 796 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
795 797 end
796 798 # Parent issue
797 799 if issue.parent_id
798 800 if copied_parent = issues_map[issue.parent_id]
799 801 new_issue.parent_issue_id = copied_parent.id
800 802 end
801 803 end
802 804
803 805 self.issues << new_issue
804 806 if new_issue.new_record?
805 807 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
806 808 else
807 809 issues_map[issue.id] = new_issue unless new_issue.new_record?
808 810 end
809 811 end
810 812
811 813 # Restore locked/closed version statuses
812 814 version_statuses.each do |version, status|
813 815 version.update_attribute :status, status
814 816 end
815 817
816 818 # Relations after in case issues related each other
817 819 project.issues.each do |issue|
818 820 new_issue = issues_map[issue.id]
819 821 unless new_issue
820 822 # Issue was not copied
821 823 next
822 824 end
823 825
824 826 # Relations
825 827 issue.relations_from.each do |source_relation|
826 828 new_issue_relation = IssueRelation.new
827 829 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
828 830 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
829 831 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
830 832 new_issue_relation.issue_to = source_relation.issue_to
831 833 end
832 834 new_issue.relations_from << new_issue_relation
833 835 end
834 836
835 837 issue.relations_to.each do |source_relation|
836 838 new_issue_relation = IssueRelation.new
837 839 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
838 840 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
839 841 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
840 842 new_issue_relation.issue_from = source_relation.issue_from
841 843 end
842 844 new_issue.relations_to << new_issue_relation
843 845 end
844 846 end
845 847 end
846 848
847 849 # Copies members from +project+
848 850 def copy_members(project)
849 851 # Copy users first, then groups to handle members with inherited and given roles
850 852 members_to_copy = []
851 853 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
852 854 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
853 855
854 856 members_to_copy.each do |member|
855 857 new_member = Member.new
856 858 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
857 859 # only copy non inherited roles
858 860 # inherited roles will be added when copying the group membership
859 861 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
860 862 next if role_ids.empty?
861 863 new_member.role_ids = role_ids
862 864 new_member.project = self
863 865 self.members << new_member
864 866 end
865 867 end
866 868
867 869 # Copies queries from +project+
868 870 def copy_queries(project)
869 871 project.queries.each do |query|
870 872 new_query = ::Query.new
871 873 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
872 874 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
873 875 new_query.project = self
874 876 new_query.user_id = query.user_id
875 877 self.queries << new_query
876 878 end
877 879 end
878 880
879 881 # Copies boards from +project+
880 882 def copy_boards(project)
881 883 project.boards.each do |board|
882 884 new_board = Board.new
883 885 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
884 886 new_board.project = self
885 887 self.boards << new_board
886 888 end
887 889 end
888 890
889 891 def allowed_permissions
890 892 @allowed_permissions ||= begin
891 893 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
892 894 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
893 895 end
894 896 end
895 897
896 898 def allowed_actions
897 899 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
898 900 end
899 901
900 902 # Returns all the active Systemwide and project specific activities
901 903 def active_activities
902 904 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
903 905
904 906 if overridden_activity_ids.empty?
905 907 return TimeEntryActivity.shared.active
906 908 else
907 909 return system_activities_and_project_overrides
908 910 end
909 911 end
910 912
911 913 # Returns all the Systemwide and project specific activities
912 914 # (inactive and active)
913 915 def all_activities
914 916 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
915 917
916 918 if overridden_activity_ids.empty?
917 919 return TimeEntryActivity.shared
918 920 else
919 921 return system_activities_and_project_overrides(true)
920 922 end
921 923 end
922 924
923 925 # Returns the systemwide active activities merged with the project specific overrides
924 926 def system_activities_and_project_overrides(include_inactive=false)
925 927 if include_inactive
926 928 return TimeEntryActivity.shared.
927 929 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
928 930 self.time_entry_activities
929 931 else
930 932 return TimeEntryActivity.shared.active.
931 933 where("id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)).all +
932 934 self.time_entry_activities.active
933 935 end
934 936 end
935 937
936 938 # Archives subprojects recursively
937 939 def archive!
938 940 children.each do |subproject|
939 941 subproject.send :archive!
940 942 end
941 943 update_attribute :status, STATUS_ARCHIVED
942 944 end
943 945
944 946 def update_position_under_parent
945 947 set_or_update_position_under(parent)
946 948 end
947 949
948 950 # Inserts/moves the project so that target's children or root projects stay alphabetically sorted
949 951 def set_or_update_position_under(target_parent)
950 952 sibs = (target_parent.nil? ? self.class.roots : target_parent.children)
951 953 to_be_inserted_before = sibs.sort_by {|c| c.name.to_s.downcase}.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
952 954
953 955 if to_be_inserted_before
954 956 move_to_left_of(to_be_inserted_before)
955 957 elsif target_parent.nil?
956 958 if sibs.empty?
957 959 # move_to_root adds the project in first (ie. left) position
958 960 move_to_root
959 961 else
960 962 move_to_right_of(sibs.last) unless self == sibs.last
961 963 end
962 964 else
963 965 # move_to_child_of adds the project in last (ie.right) position
964 966 move_to_child_of(target_parent)
965 967 end
966 968 end
967 969 end
@@ -1,1109 +1,1107
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class 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 @inline = options.key?(:inline) ? options[:inline] : true
31 31 @caption_key = options[:caption] || "field_#{name}"
32 32 end
33 33
34 34 def caption
35 35 l(@caption_key)
36 36 end
37 37
38 38 # Returns true if the column is sortable, otherwise false
39 39 def sortable?
40 40 !@sortable.nil?
41 41 end
42 42
43 43 def sortable
44 44 @sortable.is_a?(Proc) ? @sortable.call : @sortable
45 45 end
46 46
47 47 def inline?
48 48 @inline
49 49 end
50 50
51 51 def value(issue)
52 52 issue.send name
53 53 end
54 54
55 55 def css_classes
56 56 name
57 57 end
58 58 end
59 59
60 60 class QueryCustomFieldColumn < QueryColumn
61 61
62 62 def initialize(custom_field)
63 63 self.name = "cf_#{custom_field.id}".to_sym
64 64 self.sortable = custom_field.order_statement || false
65 65 self.groupable = custom_field.group_statement || false
66 66 @inline = true
67 67 @cf = custom_field
68 68 end
69 69
70 70 def caption
71 71 @cf.name
72 72 end
73 73
74 74 def custom_field
75 75 @cf
76 76 end
77 77
78 78 def value(issue)
79 79 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
80 80 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
81 81 end
82 82
83 83 def css_classes
84 84 @css_classes ||= "#{name} #{@cf.field_format}"
85 85 end
86 86 end
87 87
88 88 class Query < ActiveRecord::Base
89 89 class StatementInvalid < ::ActiveRecord::StatementInvalid
90 90 end
91 91
92 92 belongs_to :project
93 93 belongs_to :user
94 94 serialize :filters
95 95 serialize :column_names
96 96 serialize :sort_criteria, Array
97 97
98 98 attr_protected :project_id, :user_id
99 99
100 100 validates_presence_of :name
101 101 validates_length_of :name, :maximum => 255
102 102 validate :validate_query_filters
103 103
104 104 @@operators = { "=" => :label_equals,
105 105 "!" => :label_not_equals,
106 106 "o" => :label_open_issues,
107 107 "c" => :label_closed_issues,
108 108 "!*" => :label_none,
109 109 "*" => :label_any,
110 110 ">=" => :label_greater_or_equal,
111 111 "<=" => :label_less_or_equal,
112 112 "><" => :label_between,
113 113 "<t+" => :label_in_less_than,
114 114 ">t+" => :label_in_more_than,
115 115 "><t+"=> :label_in_the_next_days,
116 116 "t+" => :label_in,
117 117 "t" => :label_today,
118 118 "w" => :label_this_week,
119 119 ">t-" => :label_less_than_ago,
120 120 "<t-" => :label_more_than_ago,
121 121 "><t-"=> :label_in_the_past_days,
122 122 "t-" => :label_ago,
123 123 "~" => :label_contains,
124 124 "!~" => :label_not_contains,
125 125 "=p" => :label_any_issues_in_project,
126 126 "=!p" => :label_any_issues_not_in_project,
127 127 "!p" => :label_no_issues_in_project}
128 128
129 129 cattr_reader :operators
130 130
131 131 @@operators_by_filter_type = { :list => [ "=", "!" ],
132 132 :list_status => [ "o", "=", "!", "c", "*" ],
133 133 :list_optional => [ "=", "!", "!*", "*" ],
134 134 :list_subprojects => [ "*", "!*", "=" ],
135 135 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "w", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
136 136 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "w", "!*", "*" ],
137 137 :string => [ "=", "~", "!", "!~", "!*", "*" ],
138 138 :text => [ "~", "!~", "!*", "*" ],
139 139 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
140 140 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
141 141 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]}
142 142
143 143 cattr_reader :operators_by_filter_type
144 144
145 145 @@available_columns = [
146 146 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
147 147 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
148 148 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
149 149 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
150 150 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
151 151 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
152 152 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
153 153 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
154 154 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
155 155 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
156 156 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
157 157 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
158 158 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
159 159 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
160 160 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
161 161 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
162 162 QueryColumn.new(:relations, :caption => :label_related_issues),
163 163 QueryColumn.new(:description, :inline => false)
164 164 ]
165 165 cattr_reader :available_columns
166 166
167 167 scope :visible, lambda {|*args|
168 168 user = args.shift || User.current
169 169 base = Project.allowed_to_condition(user, :view_issues, *args)
170 170 user_id = user.logged? ? user.id : 0
171 {
172 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
173 :include => :project
174 }
171
172 includes(:project).where("(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id)
175 173 }
176 174
177 175 def initialize(attributes=nil, *args)
178 176 super attributes
179 177 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
180 178 @is_for_all = project.nil?
181 179 end
182 180
183 181 def validate_query_filters
184 182 filters.each_key do |field|
185 183 if values_for(field)
186 184 case type_for(field)
187 185 when :integer
188 186 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
189 187 when :float
190 188 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
191 189 when :date, :date_past
192 190 case operator_for(field)
193 191 when "=", ">=", "<=", "><"
194 192 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?) }
195 193 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
196 194 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
197 195 end
198 196 end
199 197 end
200 198
201 199 add_filter_error(field, :blank) unless
202 200 # filter requires one or more values
203 201 (values_for(field) and !values_for(field).first.blank?) or
204 202 # filter doesn't require any value
205 203 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
206 204 end if filters
207 205 end
208 206
209 207 def add_filter_error(field, message)
210 208 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
211 209 errors.add(:base, m)
212 210 end
213 211
214 212 # Returns true if the query is visible to +user+ or the current user.
215 213 def visible?(user=User.current)
216 214 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
217 215 end
218 216
219 217 def editable_by?(user)
220 218 return false unless user
221 219 # Admin can edit them all and regular users can edit their private queries
222 220 return true if user.admin? || (!is_public && self.user_id == user.id)
223 221 # Members can not edit public queries that are for all project (only admin is allowed to)
224 222 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
225 223 end
226 224
227 225 def trackers
228 226 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
229 227 end
230 228
231 229 # Returns a hash of localized labels for all filter operators
232 230 def self.operators_labels
233 231 operators.inject({}) {|h, operator| h[operator.first] = l(operator.last); h}
234 232 end
235 233
236 234 def available_filters
237 235 return @available_filters if @available_filters
238 236 @available_filters = {
239 237 "status_id" => {
240 238 :type => :list_status, :order => 0,
241 239 :values => IssueStatus.sorted.all.collect{|s| [s.name, s.id.to_s] }
242 240 },
243 241 "tracker_id" => {
244 242 :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] }
245 243 },
246 244 "priority_id" => {
247 245 :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
248 246 },
249 247 "subject" => { :type => :text, :order => 8 },
250 248 "created_on" => { :type => :date_past, :order => 9 },
251 249 "updated_on" => { :type => :date_past, :order => 10 },
252 250 "start_date" => { :type => :date, :order => 11 },
253 251 "due_date" => { :type => :date, :order => 12 },
254 252 "estimated_hours" => { :type => :float, :order => 13 },
255 253 "done_ratio" => { :type => :integer, :order => 14 }
256 254 }
257 255 IssueRelation::TYPES.each do |relation_type, options|
258 256 @available_filters[relation_type] = {
259 257 :type => :relation, :order => @available_filters.size + 100,
260 258 :label => options[:name]
261 259 }
262 260 end
263 261 principals = []
264 262 if project
265 263 principals += project.principals.sort
266 264 unless project.leaf?
267 265 subprojects = project.descendants.visible.all
268 266 if subprojects.any?
269 267 @available_filters["subproject_id"] = {
270 268 :type => :list_subprojects, :order => 13,
271 269 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
272 270 }
273 271 principals += Principal.member_of(subprojects)
274 272 end
275 273 end
276 274 else
277 275 if all_projects.any?
278 276 # members of visible projects
279 277 principals += Principal.member_of(all_projects)
280 278 # project filter
281 279 project_values = []
282 280 if User.current.logged? && User.current.memberships.any?
283 281 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
284 282 end
285 283 project_values += all_projects_values
286 284 @available_filters["project_id"] = {
287 285 :type => :list, :order => 1, :values => project_values
288 286 } unless project_values.empty?
289 287 end
290 288 end
291 289 principals.uniq!
292 290 principals.sort!
293 291 users = principals.select {|p| p.is_a?(User)}
294 292
295 293 assigned_to_values = []
296 294 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
297 295 assigned_to_values += (Setting.issue_group_assignment? ?
298 296 principals : users).collect{|s| [s.name, s.id.to_s] }
299 297 @available_filters["assigned_to_id"] = {
300 298 :type => :list_optional, :order => 4, :values => assigned_to_values
301 299 } unless assigned_to_values.empty?
302 300
303 301 author_values = []
304 302 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
305 303 author_values += users.collect{|s| [s.name, s.id.to_s] }
306 304 @available_filters["author_id"] = {
307 305 :type => :list, :order => 5, :values => author_values
308 306 } unless author_values.empty?
309 307
310 308 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
311 309 @available_filters["member_of_group"] = {
312 310 :type => :list_optional, :order => 6, :values => group_values
313 311 } unless group_values.empty?
314 312
315 313 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
316 314 @available_filters["assigned_to_role"] = {
317 315 :type => :list_optional, :order => 7, :values => role_values
318 316 } unless role_values.empty?
319 317
320 318 if User.current.logged?
321 319 @available_filters["watcher_id"] = {
322 320 :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]]
323 321 }
324 322 end
325 323
326 324 if project
327 325 # project specific filters
328 326 categories = project.issue_categories.all
329 327 unless categories.empty?
330 328 @available_filters["category_id"] = {
331 329 :type => :list_optional, :order => 6,
332 330 :values => categories.collect{|s| [s.name, s.id.to_s] }
333 331 }
334 332 end
335 333 versions = project.shared_versions.all
336 334 unless versions.empty?
337 335 @available_filters["fixed_version_id"] = {
338 336 :type => :list_optional, :order => 7,
339 337 :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
340 338 }
341 339 end
342 340 add_custom_fields_filters(project.all_issue_custom_fields)
343 341 else
344 342 # global filters for cross project issue list
345 343 system_shared_versions = Version.visible.find_all_by_sharing('system')
346 344 unless system_shared_versions.empty?
347 345 @available_filters["fixed_version_id"] = {
348 346 :type => :list_optional, :order => 7,
349 347 :values => system_shared_versions.sort.collect{|s|
350 348 ["#{s.project.name} - #{s.name}", s.id.to_s]
351 349 }
352 350 }
353 351 end
354 352 add_custom_fields_filters(IssueCustomField.where(:is_filter => true, :is_for_all => true).all)
355 353 end
356 354 add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
357 355 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
358 356 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
359 357 @available_filters["is_private"] = {
360 358 :type => :list, :order => 16,
361 359 :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
362 360 }
363 361 end
364 362 Tracker.disabled_core_fields(trackers).each {|field|
365 363 @available_filters.delete field
366 364 }
367 365 @available_filters.each do |field, options|
368 366 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
369 367 end
370 368 @available_filters
371 369 end
372 370
373 371 # Returns a representation of the available filters for JSON serialization
374 372 def available_filters_as_json
375 373 json = {}
376 374 available_filters.each do |field, options|
377 375 json[field] = options.slice(:type, :name, :values).stringify_keys
378 376 end
379 377 json
380 378 end
381 379
382 380 def all_projects
383 381 @all_projects ||= Project.visible.all
384 382 end
385 383
386 384 def all_projects_values
387 385 return @all_projects_values if @all_projects_values
388 386
389 387 values = []
390 388 Project.project_tree(all_projects) do |p, level|
391 389 prefix = (level > 0 ? ('--' * level + ' ') : '')
392 390 values << ["#{prefix}#{p.name}", p.id.to_s]
393 391 end
394 392 @all_projects_values = values
395 393 end
396 394
397 395 def add_filter(field, operator, values)
398 396 # values must be an array
399 397 return unless values.nil? || values.is_a?(Array)
400 398 # check if field is defined as an available filter
401 399 if available_filters.has_key? field
402 400 filter_options = available_filters[field]
403 401 # check if operator is allowed for that filter
404 402 #if @@operators_by_filter_type[filter_options[:type]].include? operator
405 403 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
406 404 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
407 405 #end
408 406 filters[field] = {:operator => operator, :values => (values || [''])}
409 407 end
410 408 end
411 409
412 410 def add_short_filter(field, expression)
413 411 return unless expression && available_filters.has_key?(field)
414 412 field_type = available_filters[field][:type]
415 413 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
416 414 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
417 415 add_filter field, operator, $1.present? ? $1.split('|') : ['']
418 416 end || add_filter(field, '=', expression.split('|'))
419 417 end
420 418
421 419 # Add multiple filters using +add_filter+
422 420 def add_filters(fields, operators, values)
423 421 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
424 422 fields.each do |field|
425 423 add_filter(field, operators[field], values && values[field])
426 424 end
427 425 end
428 426 end
429 427
430 428 def has_filter?(field)
431 429 filters and filters[field]
432 430 end
433 431
434 432 def type_for(field)
435 433 available_filters[field][:type] if available_filters.has_key?(field)
436 434 end
437 435
438 436 def operator_for(field)
439 437 has_filter?(field) ? filters[field][:operator] : nil
440 438 end
441 439
442 440 def values_for(field)
443 441 has_filter?(field) ? filters[field][:values] : nil
444 442 end
445 443
446 444 def value_for(field, index=0)
447 445 (values_for(field) || [])[index]
448 446 end
449 447
450 448 def label_for(field)
451 449 label = available_filters[field][:name] if available_filters.has_key?(field)
452 450 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
453 451 end
454 452
455 453 def available_columns
456 454 return @available_columns if @available_columns
457 455 @available_columns = ::Query.available_columns.dup
458 456 @available_columns += (project ?
459 457 project.all_issue_custom_fields :
460 458 IssueCustomField.all
461 459 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
462 460
463 461 if User.current.allowed_to?(:view_time_entries, project, :global => true)
464 462 index = nil
465 463 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
466 464 index = (index ? index + 1 : -1)
467 465 # insert the column after estimated_hours or at the end
468 466 @available_columns.insert index, QueryColumn.new(:spent_hours,
469 467 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
470 468 :default_order => 'desc',
471 469 :caption => :label_spent_time
472 470 )
473 471 end
474 472
475 473 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
476 474 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
477 475 @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
478 476 end
479 477
480 478 disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
481 479 @available_columns.reject! {|column|
482 480 disabled_fields.include?(column.name.to_s)
483 481 }
484 482
485 483 @available_columns
486 484 end
487 485
488 486 def self.available_columns=(v)
489 487 self.available_columns = (v)
490 488 end
491 489
492 490 def self.add_available_column(column)
493 491 self.available_columns << (column) if column.is_a?(QueryColumn)
494 492 end
495 493
496 494 # Returns an array of columns that can be used to group the results
497 495 def groupable_columns
498 496 available_columns.select {|c| c.groupable}
499 497 end
500 498
501 499 # Returns a Hash of columns and the key for sorting
502 500 def sortable_columns
503 501 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
504 502 h[column.name.to_s] = column.sortable
505 503 h
506 504 })
507 505 end
508 506
509 507 def columns
510 508 # preserve the column_names order
511 509 (has_default_columns? ? default_columns_names : column_names).collect do |name|
512 510 available_columns.find { |col| col.name == name }
513 511 end.compact
514 512 end
515 513
516 514 def inline_columns
517 515 columns.select(&:inline?)
518 516 end
519 517
520 518 def block_columns
521 519 columns.reject(&:inline?)
522 520 end
523 521
524 522 def available_inline_columns
525 523 available_columns.select(&:inline?)
526 524 end
527 525
528 526 def available_block_columns
529 527 available_columns.reject(&:inline?)
530 528 end
531 529
532 530 def default_columns_names
533 531 @default_columns_names ||= begin
534 532 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
535 533
536 534 project.present? ? default_columns : [:project] | default_columns
537 535 end
538 536 end
539 537
540 538 def column_names=(names)
541 539 if names
542 540 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
543 541 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
544 542 # Set column_names to nil if default columns
545 543 if names == default_columns_names
546 544 names = nil
547 545 end
548 546 end
549 547 write_attribute(:column_names, names)
550 548 end
551 549
552 550 def has_column?(column)
553 551 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
554 552 end
555 553
556 554 def has_default_columns?
557 555 column_names.nil? || column_names.empty?
558 556 end
559 557
560 558 def sort_criteria=(arg)
561 559 c = []
562 560 if arg.is_a?(Hash)
563 561 arg = arg.keys.sort.collect {|k| arg[k]}
564 562 end
565 563 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
566 564 write_attribute(:sort_criteria, c)
567 565 end
568 566
569 567 def sort_criteria
570 568 read_attribute(:sort_criteria) || []
571 569 end
572 570
573 571 def sort_criteria_key(arg)
574 572 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
575 573 end
576 574
577 575 def sort_criteria_order(arg)
578 576 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
579 577 end
580 578
581 579 def sort_criteria_order_for(key)
582 580 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
583 581 end
584 582
585 583 # Returns the SQL sort order that should be prepended for grouping
586 584 def group_by_sort_order
587 585 if grouped? && (column = group_by_column)
588 586 order = sort_criteria_order_for(column.name) || column.default_order
589 587 column.sortable.is_a?(Array) ?
590 588 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
591 589 "#{column.sortable} #{order}"
592 590 end
593 591 end
594 592
595 593 # Returns true if the query is a grouped query
596 594 def grouped?
597 595 !group_by_column.nil?
598 596 end
599 597
600 598 def group_by_column
601 599 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
602 600 end
603 601
604 602 def group_by_statement
605 603 group_by_column.try(:groupable)
606 604 end
607 605
608 606 def project_statement
609 607 project_clauses = []
610 608 if project && !project.descendants.active.empty?
611 609 ids = [project.id]
612 610 if has_filter?("subproject_id")
613 611 case operator_for("subproject_id")
614 612 when '='
615 613 # include the selected subprojects
616 614 ids += values_for("subproject_id").each(&:to_i)
617 615 when '!*'
618 616 # main project only
619 617 else
620 618 # all subprojects
621 619 ids += project.descendants.collect(&:id)
622 620 end
623 621 elsif Setting.display_subprojects_issues?
624 622 ids += project.descendants.collect(&:id)
625 623 end
626 624 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
627 625 elsif project
628 626 project_clauses << "#{Project.table_name}.id = %d" % project.id
629 627 end
630 628 project_clauses.any? ? project_clauses.join(' AND ') : nil
631 629 end
632 630
633 631 def statement
634 632 # filters clauses
635 633 filters_clauses = []
636 634 filters.each_key do |field|
637 635 next if field == "subproject_id"
638 636 v = values_for(field).clone
639 637 next unless v and !v.empty?
640 638 operator = operator_for(field)
641 639
642 640 # "me" value subsitution
643 641 if %w(assigned_to_id author_id watcher_id).include?(field)
644 642 if v.delete("me")
645 643 if User.current.logged?
646 644 v.push(User.current.id.to_s)
647 645 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
648 646 else
649 647 v.push("0")
650 648 end
651 649 end
652 650 end
653 651
654 652 if field == 'project_id'
655 653 if v.delete('mine')
656 654 v += User.current.memberships.map(&:project_id).map(&:to_s)
657 655 end
658 656 end
659 657
660 658 if field =~ /cf_(\d+)$/
661 659 # custom field
662 660 filters_clauses << sql_for_custom_field(field, operator, v, $1)
663 661 elsif respond_to?("sql_for_#{field}_field")
664 662 # specific statement
665 663 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
666 664 else
667 665 # regular field
668 666 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
669 667 end
670 668 end if filters and valid?
671 669
672 670 filters_clauses << project_statement
673 671 filters_clauses.reject!(&:blank?)
674 672
675 673 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
676 674 end
677 675
678 676 # Returns the issue count
679 677 def issue_count
680 678 Issue.visible.count(:include => [:status, :project], :conditions => statement)
681 679 rescue ::ActiveRecord::StatementInvalid => e
682 680 raise StatementInvalid.new(e.message)
683 681 end
684 682
685 683 # Returns the issue count by group or nil if query is not grouped
686 684 def issue_count_by_group
687 685 r = nil
688 686 if grouped?
689 687 begin
690 688 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
691 689 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
692 690 rescue ActiveRecord::RecordNotFound
693 691 r = {nil => issue_count}
694 692 end
695 693 c = group_by_column
696 694 if c.is_a?(QueryCustomFieldColumn)
697 695 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
698 696 end
699 697 end
700 698 r
701 699 rescue ::ActiveRecord::StatementInvalid => e
702 700 raise StatementInvalid.new(e.message)
703 701 end
704 702
705 703 # Returns the issues
706 704 # Valid options are :order, :offset, :limit, :include, :conditions
707 705 def issues(options={})
708 706 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
709 707 order_option = nil if order_option.blank?
710 708
711 709 issues = Issue.visible.where(options[:conditions]).all(
712 710 :include => ([:status, :project] + (options[:include] || [])).uniq,
713 711 :conditions => statement,
714 712 :order => order_option,
715 713 :joins => joins_for_order_statement(order_option),
716 714 :limit => options[:limit],
717 715 :offset => options[:offset]
718 716 )
719 717
720 718 if has_column?(:spent_hours)
721 719 Issue.load_visible_spent_hours(issues)
722 720 end
723 721 if has_column?(:relations)
724 722 Issue.load_visible_relations(issues)
725 723 end
726 724 issues
727 725 rescue ::ActiveRecord::StatementInvalid => e
728 726 raise StatementInvalid.new(e.message)
729 727 end
730 728
731 729 # Returns the issues ids
732 730 def issue_ids(options={})
733 731 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
734 732 order_option = nil if order_option.blank?
735 733
736 734 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
737 735 :conditions => statement,
738 736 :order => order_option,
739 737 :joins => joins_for_order_statement(order_option),
740 738 :limit => options[:limit],
741 739 :offset => options[:offset]).find_ids
742 740 rescue ::ActiveRecord::StatementInvalid => e
743 741 raise StatementInvalid.new(e.message)
744 742 end
745 743
746 744 # Returns the journals
747 745 # Valid options are :order, :offset, :limit
748 746 def journals(options={})
749 747 Journal.visible.all(
750 748 :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
751 749 :conditions => statement,
752 750 :order => options[:order],
753 751 :limit => options[:limit],
754 752 :offset => options[:offset]
755 753 )
756 754 rescue ::ActiveRecord::StatementInvalid => e
757 755 raise StatementInvalid.new(e.message)
758 756 end
759 757
760 758 # Returns the versions
761 759 # Valid options are :conditions
762 760 def versions(options={})
763 761 Version.visible.where(options[:conditions]).all(
764 762 :include => :project,
765 763 :conditions => project_statement
766 764 )
767 765 rescue ::ActiveRecord::StatementInvalid => e
768 766 raise StatementInvalid.new(e.message)
769 767 end
770 768
771 769 def sql_for_watcher_id_field(field, operator, value)
772 770 db_table = Watcher.table_name
773 771 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
774 772 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
775 773 end
776 774
777 775 def sql_for_member_of_group_field(field, operator, value)
778 776 if operator == '*' # Any group
779 777 groups = Group.all
780 778 operator = '=' # Override the operator since we want to find by assigned_to
781 779 elsif operator == "!*"
782 780 groups = Group.all
783 781 operator = '!' # Override the operator since we want to find by assigned_to
784 782 else
785 783 groups = Group.find_all_by_id(value)
786 784 end
787 785 groups ||= []
788 786
789 787 members_of_groups = groups.inject([]) {|user_ids, group|
790 788 if group && group.user_ids.present?
791 789 user_ids << group.user_ids
792 790 end
793 791 user_ids.flatten.uniq.compact
794 792 }.sort.collect(&:to_s)
795 793
796 794 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
797 795 end
798 796
799 797 def sql_for_assigned_to_role_field(field, operator, value)
800 798 case operator
801 799 when "*", "!*" # Member / Not member
802 800 sw = operator == "!*" ? 'NOT' : ''
803 801 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
804 802 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
805 803 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
806 804 when "=", "!"
807 805 role_cond = value.any? ?
808 806 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
809 807 "1=0"
810 808
811 809 sw = operator == "!" ? 'NOT' : ''
812 810 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
813 811 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
814 812 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
815 813 end
816 814 end
817 815
818 816 def sql_for_is_private_field(field, operator, value)
819 817 op = (operator == "=" ? 'IN' : 'NOT IN')
820 818 va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
821 819
822 820 "#{Issue.table_name}.is_private #{op} (#{va})"
823 821 end
824 822
825 823 def sql_for_relations(field, operator, value, options={})
826 824 relation_options = IssueRelation::TYPES[field]
827 825 return relation_options unless relation_options
828 826
829 827 relation_type = field
830 828 join_column, target_join_column = "issue_from_id", "issue_to_id"
831 829 if relation_options[:reverse] || options[:reverse]
832 830 relation_type = relation_options[:reverse] || relation_type
833 831 join_column, target_join_column = target_join_column, join_column
834 832 end
835 833
836 834 sql = case operator
837 835 when "*", "!*"
838 836 op = (operator == "*" ? 'IN' : 'NOT IN')
839 837 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
840 838 when "=", "!"
841 839 op = (operator == "=" ? 'IN' : 'NOT IN')
842 840 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
843 841 when "=p", "=!p", "!p"
844 842 op = (operator == "!p" ? 'NOT IN' : 'IN')
845 843 comp = (operator == "=!p" ? '<>' : '=')
846 844 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
847 845 end
848 846
849 847 if relation_options[:sym] == field && !options[:reverse]
850 848 sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
851 849 sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
852 850 else
853 851 sql
854 852 end
855 853 end
856 854
857 855 IssueRelation::TYPES.keys.each do |relation_type|
858 856 alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
859 857 end
860 858
861 859 private
862 860
863 861 def sql_for_custom_field(field, operator, value, custom_field_id)
864 862 db_table = CustomValue.table_name
865 863 db_field = 'value'
866 864 filter = @available_filters[field]
867 865 return nil unless filter
868 866 if filter[:format] == 'user'
869 867 if value.delete('me')
870 868 value.push User.current.id.to_s
871 869 end
872 870 end
873 871 not_in = nil
874 872 if operator == '!'
875 873 # Makes ! operator work for custom fields with multiple values
876 874 operator = '='
877 875 not_in = 'NOT'
878 876 end
879 877 customized_key = "id"
880 878 customized_class = Issue
881 879 if field =~ /^(.+)\.cf_/
882 880 assoc = $1
883 881 customized_key = "#{assoc}_id"
884 882 customized_class = Issue.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
885 883 raise "Unknown Issue association #{assoc}" unless customized_class
886 884 end
887 885 "#{Issue.table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
888 886 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
889 887 end
890 888
891 889 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
892 890 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
893 891 sql = ''
894 892 case operator
895 893 when "="
896 894 if value.any?
897 895 case type_for(field)
898 896 when :date, :date_past
899 897 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
900 898 when :integer
901 899 if is_custom_filter
902 900 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
903 901 else
904 902 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
905 903 end
906 904 when :float
907 905 if is_custom_filter
908 906 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})"
909 907 else
910 908 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
911 909 end
912 910 else
913 911 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
914 912 end
915 913 else
916 914 # IN an empty set
917 915 sql = "1=0"
918 916 end
919 917 when "!"
920 918 if value.any?
921 919 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
922 920 else
923 921 # NOT IN an empty set
924 922 sql = "1=1"
925 923 end
926 924 when "!*"
927 925 sql = "#{db_table}.#{db_field} IS NULL"
928 926 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
929 927 when "*"
930 928 sql = "#{db_table}.#{db_field} IS NOT NULL"
931 929 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
932 930 when ">="
933 931 if [:date, :date_past].include?(type_for(field))
934 932 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
935 933 else
936 934 if is_custom_filter
937 935 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
938 936 else
939 937 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
940 938 end
941 939 end
942 940 when "<="
943 941 if [:date, :date_past].include?(type_for(field))
944 942 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
945 943 else
946 944 if is_custom_filter
947 945 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
948 946 else
949 947 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
950 948 end
951 949 end
952 950 when "><"
953 951 if [:date, :date_past].include?(type_for(field))
954 952 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
955 953 else
956 954 if is_custom_filter
957 955 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})"
958 956 else
959 957 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
960 958 end
961 959 end
962 960 when "o"
963 961 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
964 962 when "c"
965 963 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
966 964 when "><t-"
967 965 # between today - n days and today
968 966 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
969 967 when ">t-"
970 968 # >= today - n days
971 969 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
972 970 when "<t-"
973 971 # <= today - n days
974 972 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
975 973 when "t-"
976 974 # = n days in past
977 975 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
978 976 when "><t+"
979 977 # between today and today + n days
980 978 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
981 979 when ">t+"
982 980 # >= today + n days
983 981 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
984 982 when "<t+"
985 983 # <= today + n days
986 984 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
987 985 when "t+"
988 986 # = today + n days
989 987 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
990 988 when "t"
991 989 # = today
992 990 sql = relative_date_clause(db_table, db_field, 0, 0)
993 991 when "w"
994 992 # = this week
995 993 first_day_of_week = l(:general_first_day_of_week).to_i
996 994 day_of_week = Date.today.cwday
997 995 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
998 996 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
999 997 when "~"
1000 998 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
1001 999 when "!~"
1002 1000 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
1003 1001 else
1004 1002 raise "Unknown query operator #{operator}"
1005 1003 end
1006 1004
1007 1005 return sql
1008 1006 end
1009 1007
1010 1008 def add_custom_fields_filters(custom_fields, assoc=nil)
1011 1009 return unless custom_fields.present?
1012 1010 @available_filters ||= {}
1013 1011
1014 1012 custom_fields.select(&:is_filter?).each do |field|
1015 1013 case field.field_format
1016 1014 when "text"
1017 1015 options = { :type => :text, :order => 20 }
1018 1016 when "list"
1019 1017 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
1020 1018 when "date"
1021 1019 options = { :type => :date, :order => 20 }
1022 1020 when "bool"
1023 1021 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
1024 1022 when "int"
1025 1023 options = { :type => :integer, :order => 20 }
1026 1024 when "float"
1027 1025 options = { :type => :float, :order => 20 }
1028 1026 when "user", "version"
1029 1027 next unless project
1030 1028 values = field.possible_values_options(project)
1031 1029 if User.current.logged? && field.field_format == 'user'
1032 1030 values.unshift ["<< #{l(:label_me)} >>", "me"]
1033 1031 end
1034 1032 options = { :type => :list_optional, :values => values, :order => 20}
1035 1033 else
1036 1034 options = { :type => :string, :order => 20 }
1037 1035 end
1038 1036 filter_id = "cf_#{field.id}"
1039 1037 filter_name = field.name
1040 1038 if assoc.present?
1041 1039 filter_id = "#{assoc}.#{filter_id}"
1042 1040 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1043 1041 end
1044 1042 @available_filters[filter_id] = options.merge({
1045 1043 :name => filter_name,
1046 1044 :format => field.field_format,
1047 1045 :field => field
1048 1046 })
1049 1047 end
1050 1048 end
1051 1049
1052 1050 def add_associations_custom_fields_filters(*associations)
1053 1051 fields_by_class = CustomField.where(:is_filter => true).group_by(&:class)
1054 1052 associations.each do |assoc|
1055 1053 association_klass = Issue.reflect_on_association(assoc).klass
1056 1054 fields_by_class.each do |field_class, fields|
1057 1055 if field_class.customized_class <= association_klass
1058 1056 add_custom_fields_filters(fields, assoc)
1059 1057 end
1060 1058 end
1061 1059 end
1062 1060 end
1063 1061
1064 1062 # Returns a SQL clause for a date or datetime field.
1065 1063 def date_clause(table, field, from, to)
1066 1064 s = []
1067 1065 if from
1068 1066 from_yesterday = from - 1
1069 1067 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
1070 1068 if self.class.default_timezone == :utc
1071 1069 from_yesterday_time = from_yesterday_time.utc
1072 1070 end
1073 1071 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
1074 1072 end
1075 1073 if to
1076 1074 to_time = Time.local(to.year, to.month, to.day)
1077 1075 if self.class.default_timezone == :utc
1078 1076 to_time = to_time.utc
1079 1077 end
1080 1078 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
1081 1079 end
1082 1080 s.join(' AND ')
1083 1081 end
1084 1082
1085 1083 # Returns a SQL clause for a date or datetime field using relative dates.
1086 1084 def relative_date_clause(table, field, days_from, days_to)
1087 1085 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
1088 1086 end
1089 1087
1090 1088 # Additional joins required for the given sort options
1091 1089 def joins_for_order_statement(order_options)
1092 1090 joins = []
1093 1091
1094 1092 if order_options
1095 1093 if order_options.include?('authors')
1096 1094 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{Issue.table_name}.author_id"
1097 1095 end
1098 1096 order_options.scan(/cf_\d+/).uniq.each do |name|
1099 1097 column = available_columns.detect {|c| c.name.to_s == name}
1100 1098 join = column && column.custom_field.join_for_order_statement
1101 1099 if join
1102 1100 joins << join
1103 1101 end
1104 1102 end
1105 1103 end
1106 1104
1107 1105 joins.any? ? joins.join(' ') : nil
1108 1106 end
1109 1107 end
@@ -1,120 +1,117
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class 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 scope :visible, lambda {|*args| {
46 :include => :project,
47 :conditions => Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args)
48 }}
49 scope :on_issue, lambda {|issue| {
50 :include => :issue,
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 }}
53 scope :on_project, lambda {|project, include_subprojects| {
54 :include => :project,
55 :conditions => project.project_condition(include_subprojects)
56 }}
45 scope :visible, lambda {|*args|
46 includes(:project).where(Project.allowed_to_condition(args.shift || User.current, :view_time_entries, *args))
47 }
48 scope :on_issue, lambda {|issue|
49 includes(:issue).where("#{Issue.table_name}.root_id = #{issue.root_id} AND #{Issue.table_name}.lft >= #{issue.lft} AND #{Issue.table_name}.rgt <= #{issue.rgt}")
50 }
51 scope :on_project, lambda {|project, include_subprojects|
52 includes(:project).where(project.project_condition(include_subprojects))
53 }
57 54 scope :spent_between, lambda {|from, to|
58 55 if from && to
59 {:conditions => ["#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to]}
56 where("#{TimeEntry.table_name}.spent_on BETWEEN ? AND ?", from, to)
60 57 elsif from
61 {:conditions => ["#{TimeEntry.table_name}.spent_on >= ?", from]}
58 where("#{TimeEntry.table_name}.spent_on >= ?", from)
62 59 elsif to
63 {:conditions => ["#{TimeEntry.table_name}.spent_on <= ?", to]}
60 where("#{TimeEntry.table_name}.spent_on <= ?", to)
64 61 else
65 {}
62 where(nil)
66 63 end
67 64 }
68 65
69 66 safe_attributes 'hours', 'comments', 'issue_id', 'activity_id', 'spent_on', 'custom_field_values', 'custom_fields'
70 67
71 68 def initialize(attributes=nil, *args)
72 69 super
73 70 if new_record? && self.activity.nil?
74 71 if default_activity = TimeEntryActivity.default
75 72 self.activity_id = default_activity.id
76 73 end
77 74 self.hours = nil if hours == 0
78 75 end
79 76 end
80 77
81 78 def set_project_if_nil
82 79 self.project = issue.project if issue && project.nil?
83 80 end
84 81
85 82 def validate_time_entry
86 83 errors.add :hours, :invalid if hours && (hours < 0 || hours >= 1000)
87 84 errors.add :project_id, :invalid if project.nil?
88 85 errors.add :issue_id, :invalid if (issue_id && !issue) || (issue && project!=issue.project)
89 86 end
90 87
91 88 def hours=(h)
92 89 write_attribute :hours, (h.is_a?(String) ? (h.to_hours || h) : h)
93 90 end
94 91
95 92 def hours
96 93 h = read_attribute(:hours)
97 94 if h.is_a?(Float)
98 95 h.round(2)
99 96 else
100 97 h
101 98 end
102 99 end
103 100
104 101 # tyear, tmonth, tweek assigned where setting spent_on attributes
105 102 # these attributes make time aggregations easier
106 103 def spent_on=(date)
107 104 super
108 105 if spent_on.is_a?(Time)
109 106 self.spent_on = spent_on.to_date
110 107 end
111 108 self.tyear = spent_on ? spent_on.year : nil
112 109 self.tmonth = spent_on ? spent_on.month : nil
113 110 self.tweek = spent_on ? Date.civil(spent_on.year, spent_on.month, spent_on.day).cweek : nil
114 111 end
115 112
116 113 # Returns true if the time entry can be edited by usr, otherwise false
117 114 def editable_by?(usr)
118 115 (usr == user && usr.allowed_to?(:edit_own_time_entries, project)) || usr.allowed_to?(:edit_time_entries, project)
119 116 end
120 117 end
@@ -1,712 +1,712
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # 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 => {
32 32 :string => '#{firstname} #{lastname}',
33 33 :order => %w(firstname lastname id),
34 34 :setting_order => 1
35 35 },
36 36 :firstname_lastinitial => {
37 37 :string => '#{firstname} #{lastname.to_s.chars.first}.',
38 38 :order => %w(firstname lastname id),
39 39 :setting_order => 2
40 40 },
41 41 :firstname => {
42 42 :string => '#{firstname}',
43 43 :order => %w(firstname id),
44 44 :setting_order => 3
45 45 },
46 46 :lastname_firstname => {
47 47 :string => '#{lastname} #{firstname}',
48 48 :order => %w(lastname firstname id),
49 49 :setting_order => 4
50 50 },
51 51 :lastname_coma_firstname => {
52 52 :string => '#{lastname}, #{firstname}',
53 53 :order => %w(lastname firstname id),
54 54 :setting_order => 5
55 55 },
56 56 :lastname => {
57 57 :string => '#{lastname}',
58 58 :order => %w(lastname id),
59 59 :setting_order => 6
60 60 },
61 61 :username => {
62 62 :string => '#{login}',
63 63 :order => %w(login id),
64 64 :setting_order => 7
65 65 },
66 66 }
67 67
68 68 MAIL_NOTIFICATION_OPTIONS = [
69 69 ['all', :label_user_mail_option_all],
70 70 ['selected', :label_user_mail_option_selected],
71 71 ['only_my_events', :label_user_mail_option_only_my_events],
72 72 ['only_assigned', :label_user_mail_option_only_assigned],
73 73 ['only_owner', :label_user_mail_option_only_owner],
74 74 ['none', :label_user_mail_option_none]
75 75 ]
76 76
77 77 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
78 78 :after_remove => Proc.new {|user, group| group.user_removed(user)}
79 79 has_many :changesets, :dependent => :nullify
80 80 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
81 81 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
82 82 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
83 83 belongs_to :auth_source
84 84
85 scope :logged, lambda { { :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}" } }
86 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
85 scope :logged, lambda { where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}") }
86 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
87 87
88 88 acts_as_customizable
89 89
90 90 attr_accessor :password, :password_confirmation
91 91 attr_accessor :last_before_login_on
92 92 # Prevents unauthorized assignments
93 93 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
94 94
95 95 LOGIN_LENGTH_LIMIT = 60
96 96 MAIL_LENGTH_LIMIT = 60
97 97
98 98 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
99 99 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
100 100 validates_uniqueness_of :mail, :if => Proc.new { |user| user.mail_changed? && user.mail.present? }, :case_sensitive => false
101 101 # Login must contain lettres, numbers, underscores only
102 102 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
103 103 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
104 104 validates_length_of :firstname, :lastname, :maximum => 30
105 105 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
106 106 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
107 107 validates_confirmation_of :password, :allow_nil => true
108 108 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
109 109 validate :validate_password_length
110 110
111 111 before_create :set_mail_notification
112 112 before_save :update_hashed_password
113 113 before_destroy :remove_references_before_destroy
114 114
115 115 scope :in_group, lambda {|group|
116 116 group_id = group.is_a?(Group) ? group.id : group.to_i
117 117 where("#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
118 118 }
119 119 scope :not_in_group, lambda {|group|
120 120 group_id = group.is_a?(Group) ? group.id : group.to_i
121 121 where("#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id)
122 122 }
123 123
124 124 def set_mail_notification
125 125 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
126 126 true
127 127 end
128 128
129 129 def update_hashed_password
130 130 # update hashed_password if password was set
131 131 if self.password && self.auth_source_id.blank?
132 132 salt_password(password)
133 133 end
134 134 end
135 135
136 136 def reload(*args)
137 137 @name = nil
138 138 @projects_by_role = nil
139 139 super
140 140 end
141 141
142 142 def mail=(arg)
143 143 write_attribute(:mail, arg.to_s.strip)
144 144 end
145 145
146 146 def identity_url=(url)
147 147 if url.blank?
148 148 write_attribute(:identity_url, '')
149 149 else
150 150 begin
151 151 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
152 152 rescue OpenIdAuthentication::InvalidOpenId
153 153 # Invlaid url, don't save
154 154 end
155 155 end
156 156 self.read_attribute(:identity_url)
157 157 end
158 158
159 159 # Returns the user that matches provided login and password, or nil
160 160 def self.try_to_login(login, password)
161 161 login = login.to_s
162 162 password = password.to_s
163 163
164 164 # Make sure no one can sign in with an empty password
165 165 return nil if password.empty?
166 166 user = find_by_login(login)
167 167 if user
168 168 # user is already in local database
169 169 return nil if !user.active?
170 170 if user.auth_source
171 171 # user has an external authentication method
172 172 return nil unless user.auth_source.authenticate(login, password)
173 173 else
174 174 # authentication with local password
175 175 return nil unless user.check_password?(password)
176 176 end
177 177 else
178 178 # user is not yet registered, try to authenticate with available sources
179 179 attrs = AuthSource.authenticate(login, password)
180 180 if attrs
181 181 user = new(attrs)
182 182 user.login = login
183 183 user.language = Setting.default_language
184 184 if user.save
185 185 user.reload
186 186 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
187 187 end
188 188 end
189 189 end
190 190 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
191 191 user
192 192 rescue => text
193 193 raise text
194 194 end
195 195
196 196 # Returns the user who matches the given autologin +key+ or nil
197 197 def self.try_to_autologin(key)
198 198 tokens = Token.find_all_by_action_and_value('autologin', key.to_s)
199 199 # Make sure there's only 1 token that matches the key
200 200 if tokens.size == 1
201 201 token = tokens.first
202 202 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
203 203 token.user.update_attribute(:last_login_on, Time.now)
204 204 token.user
205 205 end
206 206 end
207 207 end
208 208
209 209 def self.name_formatter(formatter = nil)
210 210 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
211 211 end
212 212
213 213 # Returns an array of fields names than can be used to make an order statement for users
214 214 # according to how user names are displayed
215 215 # Examples:
216 216 #
217 217 # User.fields_for_order_statement => ['users.login', 'users.id']
218 218 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
219 219 def self.fields_for_order_statement(table=nil)
220 220 table ||= table_name
221 221 name_formatter[:order].map {|field| "#{table}.#{field}"}
222 222 end
223 223
224 224 # Return user's full name for display
225 225 def name(formatter = nil)
226 226 f = self.class.name_formatter(formatter)
227 227 if formatter
228 228 eval('"' + f[:string] + '"')
229 229 else
230 230 @name ||= eval('"' + f[:string] + '"')
231 231 end
232 232 end
233 233
234 234 def active?
235 235 self.status == STATUS_ACTIVE
236 236 end
237 237
238 238 def registered?
239 239 self.status == STATUS_REGISTERED
240 240 end
241 241
242 242 def locked?
243 243 self.status == STATUS_LOCKED
244 244 end
245 245
246 246 def activate
247 247 self.status = STATUS_ACTIVE
248 248 end
249 249
250 250 def register
251 251 self.status = STATUS_REGISTERED
252 252 end
253 253
254 254 def lock
255 255 self.status = STATUS_LOCKED
256 256 end
257 257
258 258 def activate!
259 259 update_attribute(:status, STATUS_ACTIVE)
260 260 end
261 261
262 262 def register!
263 263 update_attribute(:status, STATUS_REGISTERED)
264 264 end
265 265
266 266 def lock!
267 267 update_attribute(:status, STATUS_LOCKED)
268 268 end
269 269
270 270 # Returns true if +clear_password+ is the correct user's password, otherwise false
271 271 def check_password?(clear_password)
272 272 if auth_source_id.present?
273 273 auth_source.authenticate(self.login, clear_password)
274 274 else
275 275 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
276 276 end
277 277 end
278 278
279 279 # Generates a random salt and computes hashed_password for +clear_password+
280 280 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
281 281 def salt_password(clear_password)
282 282 self.salt = User.generate_salt
283 283 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
284 284 end
285 285
286 286 # Does the backend storage allow this user to change their password?
287 287 def change_password_allowed?
288 288 return true if auth_source.nil?
289 289 return auth_source.allow_password_changes?
290 290 end
291 291
292 292 # Generate and set a random password. Useful for automated user creation
293 293 # Based on Token#generate_token_value
294 294 #
295 295 def random_password
296 296 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
297 297 password = ''
298 298 40.times { |i| password << chars[rand(chars.size-1)] }
299 299 self.password = password
300 300 self.password_confirmation = password
301 301 self
302 302 end
303 303
304 304 def pref
305 305 self.preference ||= UserPreference.new(:user => self)
306 306 end
307 307
308 308 def time_zone
309 309 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
310 310 end
311 311
312 312 def wants_comments_in_reverse_order?
313 313 self.pref[:comments_sorting] == 'desc'
314 314 end
315 315
316 316 # Return user's RSS key (a 40 chars long string), used to access feeds
317 317 def rss_key
318 318 if rss_token.nil?
319 319 create_rss_token(:action => 'feeds')
320 320 end
321 321 rss_token.value
322 322 end
323 323
324 324 # Return user's API key (a 40 chars long string), used to access the API
325 325 def api_key
326 326 if api_token.nil?
327 327 create_api_token(:action => 'api')
328 328 end
329 329 api_token.value
330 330 end
331 331
332 332 # Return an array of project ids for which the user has explicitly turned mail notifications on
333 333 def notified_projects_ids
334 334 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
335 335 end
336 336
337 337 def notified_project_ids=(ids)
338 338 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
339 339 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
340 340 @notified_projects_ids = nil
341 341 notified_projects_ids
342 342 end
343 343
344 344 def valid_notification_options
345 345 self.class.valid_notification_options(self)
346 346 end
347 347
348 348 # Only users that belong to more than 1 project can select projects for which they are notified
349 349 def self.valid_notification_options(user=nil)
350 350 # Note that @user.membership.size would fail since AR ignores
351 351 # :include association option when doing a count
352 352 if user.nil? || user.memberships.length < 1
353 353 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
354 354 else
355 355 MAIL_NOTIFICATION_OPTIONS
356 356 end
357 357 end
358 358
359 359 # Find a user account by matching the exact login and then a case-insensitive
360 360 # version. Exact matches will be given priority.
361 361 def self.find_by_login(login)
362 362 # First look for an exact match
363 363 user = where(:login => login).all.detect {|u| u.login == login}
364 364 unless user
365 365 # Fail over to case-insensitive if none was found
366 366 user = where("LOWER(login) = ?", login.to_s.downcase).first
367 367 end
368 368 user
369 369 end
370 370
371 371 def self.find_by_rss_key(key)
372 372 token = Token.find_by_action_and_value('feeds', key.to_s)
373 373 token && token.user.active? ? token.user : nil
374 374 end
375 375
376 376 def self.find_by_api_key(key)
377 377 token = Token.find_by_action_and_value('api', key.to_s)
378 378 token && token.user.active? ? token.user : nil
379 379 end
380 380
381 381 # Makes find_by_mail case-insensitive
382 382 def self.find_by_mail(mail)
383 383 where("LOWER(mail) = ?", mail.to_s.downcase).first
384 384 end
385 385
386 386 # Returns true if the default admin account can no longer be used
387 387 def self.default_admin_account_changed?
388 388 !User.active.find_by_login("admin").try(:check_password?, "admin")
389 389 end
390 390
391 391 def to_s
392 392 name
393 393 end
394 394
395 395 CSS_CLASS_BY_STATUS = {
396 396 STATUS_ANONYMOUS => 'anon',
397 397 STATUS_ACTIVE => 'active',
398 398 STATUS_REGISTERED => 'registered',
399 399 STATUS_LOCKED => 'locked'
400 400 }
401 401
402 402 def css_classes
403 403 "user #{CSS_CLASS_BY_STATUS[status]}"
404 404 end
405 405
406 406 # Returns the current day according to user's time zone
407 407 def today
408 408 if time_zone.nil?
409 409 Date.today
410 410 else
411 411 Time.now.in_time_zone(time_zone).to_date
412 412 end
413 413 end
414 414
415 415 # Returns the day of +time+ according to user's time zone
416 416 def time_to_date(time)
417 417 if time_zone.nil?
418 418 time.to_date
419 419 else
420 420 time.in_time_zone(time_zone).to_date
421 421 end
422 422 end
423 423
424 424 def logged?
425 425 true
426 426 end
427 427
428 428 def anonymous?
429 429 !logged?
430 430 end
431 431
432 432 # Return user's roles for project
433 433 def roles_for_project(project)
434 434 roles = []
435 435 # No role on archived projects
436 436 return roles if project.nil? || project.archived?
437 437 if logged?
438 438 # Find project membership
439 439 membership = memberships.detect {|m| m.project_id == project.id}
440 440 if membership
441 441 roles = membership.roles
442 442 else
443 443 @role_non_member ||= Role.non_member
444 444 roles << @role_non_member
445 445 end
446 446 else
447 447 @role_anonymous ||= Role.anonymous
448 448 roles << @role_anonymous
449 449 end
450 450 roles
451 451 end
452 452
453 453 # Return true if the user is a member of project
454 454 def member_of?(project)
455 455 !roles_for_project(project).detect {|role| role.member?}.nil?
456 456 end
457 457
458 458 # Returns a hash of user's projects grouped by roles
459 459 def projects_by_role
460 460 return @projects_by_role if @projects_by_role
461 461
462 462 @projects_by_role = Hash.new([])
463 463 memberships.each do |membership|
464 464 if membership.project
465 465 membership.roles.each do |role|
466 466 @projects_by_role[role] = [] unless @projects_by_role.key?(role)
467 467 @projects_by_role[role] << membership.project
468 468 end
469 469 end
470 470 end
471 471 @projects_by_role.each do |role, projects|
472 472 projects.uniq!
473 473 end
474 474
475 475 @projects_by_role
476 476 end
477 477
478 478 # Returns true if user is arg or belongs to arg
479 479 def is_or_belongs_to?(arg)
480 480 if arg.is_a?(User)
481 481 self == arg
482 482 elsif arg.is_a?(Group)
483 483 arg.users.include?(self)
484 484 else
485 485 false
486 486 end
487 487 end
488 488
489 489 # Return true if the user is allowed to do the specified action on a specific context
490 490 # Action can be:
491 491 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
492 492 # * a permission Symbol (eg. :edit_project)
493 493 # Context can be:
494 494 # * a project : returns true if user is allowed to do the specified action on this project
495 495 # * an array of projects : returns true if user is allowed on every project
496 496 # * nil with options[:global] set : check if user has at least one role allowed for this action,
497 497 # or falls back to Non Member / Anonymous permissions depending if the user is logged
498 498 def allowed_to?(action, context, options={}, &block)
499 499 if context && context.is_a?(Project)
500 500 return false unless context.allows_to?(action)
501 501 # Admin users are authorized for anything else
502 502 return true if admin?
503 503
504 504 roles = roles_for_project(context)
505 505 return false unless roles
506 506 roles.any? {|role|
507 507 (context.is_public? || role.member?) &&
508 508 role.allowed_to?(action) &&
509 509 (block_given? ? yield(role, self) : true)
510 510 }
511 511 elsif context && context.is_a?(Array)
512 512 if context.empty?
513 513 false
514 514 else
515 515 # Authorize if user is authorized on every element of the array
516 516 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
517 517 end
518 518 elsif options[:global]
519 519 # Admin users are always authorized
520 520 return true if admin?
521 521
522 522 # authorize if user has at least one role that has this permission
523 523 roles = memberships.collect {|m| m.roles}.flatten.uniq
524 524 roles << (self.logged? ? Role.non_member : Role.anonymous)
525 525 roles.any? {|role|
526 526 role.allowed_to?(action) &&
527 527 (block_given? ? yield(role, self) : true)
528 528 }
529 529 else
530 530 false
531 531 end
532 532 end
533 533
534 534 # Is the user allowed to do the specified action on any project?
535 535 # See allowed_to? for the actions and valid options.
536 536 def allowed_to_globally?(action, options, &block)
537 537 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
538 538 end
539 539
540 540 # Returns true if the user is allowed to delete his own account
541 541 def own_account_deletable?
542 542 Setting.unsubscribe? &&
543 543 (!admin? || User.active.where("admin = ? AND id <> ?", true, id).exists?)
544 544 end
545 545
546 546 safe_attributes 'login',
547 547 'firstname',
548 548 'lastname',
549 549 'mail',
550 550 'mail_notification',
551 551 'language',
552 552 'custom_field_values',
553 553 'custom_fields',
554 554 'identity_url'
555 555
556 556 safe_attributes 'status',
557 557 'auth_source_id',
558 558 :if => lambda {|user, current_user| current_user.admin?}
559 559
560 560 safe_attributes 'group_ids',
561 561 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
562 562
563 563 # Utility method to help check if a user should be notified about an
564 564 # event.
565 565 #
566 566 # TODO: only supports Issue events currently
567 567 def notify_about?(object)
568 568 case mail_notification
569 569 when 'all'
570 570 true
571 571 when 'selected'
572 572 # user receives notifications for created/assigned issues on unselected projects
573 573 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
574 574 true
575 575 else
576 576 false
577 577 end
578 578 when 'none'
579 579 false
580 580 when 'only_my_events'
581 581 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
582 582 true
583 583 else
584 584 false
585 585 end
586 586 when 'only_assigned'
587 587 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
588 588 true
589 589 else
590 590 false
591 591 end
592 592 when 'only_owner'
593 593 if object.is_a?(Issue) && object.author == self
594 594 true
595 595 else
596 596 false
597 597 end
598 598 else
599 599 false
600 600 end
601 601 end
602 602
603 603 def self.current=(user)
604 604 Thread.current[:current_user] = user
605 605 end
606 606
607 607 def self.current
608 608 Thread.current[:current_user] ||= User.anonymous
609 609 end
610 610
611 611 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
612 612 # one anonymous user per database.
613 613 def self.anonymous
614 614 anonymous_user = AnonymousUser.first
615 615 if anonymous_user.nil?
616 616 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
617 617 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
618 618 end
619 619 anonymous_user
620 620 end
621 621
622 622 # Salts all existing unsalted passwords
623 623 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
624 624 # This method is used in the SaltPasswords migration and is to be kept as is
625 625 def self.salt_unsalted_passwords!
626 626 transaction do
627 627 User.where("salt IS NULL OR salt = ''").find_each do |user|
628 628 next if user.hashed_password.blank?
629 629 salt = User.generate_salt
630 630 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
631 631 User.where(:id => user.id).update_all(:salt => salt, :hashed_password => hashed_password)
632 632 end
633 633 end
634 634 end
635 635
636 636 protected
637 637
638 638 def validate_password_length
639 639 # Password length validation based on setting
640 640 if !password.nil? && password.size < Setting.password_min_length.to_i
641 641 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
642 642 end
643 643 end
644 644
645 645 private
646 646
647 647 # Removes references that are not handled by associations
648 648 # Things that are not deleted are reassociated with the anonymous user
649 649 def remove_references_before_destroy
650 650 return if self.id.nil?
651 651
652 652 substitute = User.anonymous
653 653 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
654 654 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
655 655 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
656 656 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
657 657 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
658 658 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
659 659 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
660 660 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
661 661 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
662 662 # Remove private queries and keep public ones
663 663 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
664 664 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
665 665 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
666 666 Token.delete_all ['user_id = ?', id]
667 667 Watcher.delete_all ['user_id = ?', id]
668 668 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
669 669 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
670 670 end
671 671
672 672 # Return password digest
673 673 def self.hash_password(clear_password)
674 674 Digest::SHA1.hexdigest(clear_password || "")
675 675 end
676 676
677 677 # Returns a 128bits random salt as a hex string (32 chars long)
678 678 def self.generate_salt
679 679 Redmine::Utils.random_hex(16)
680 680 end
681 681
682 682 end
683 683
684 684 class AnonymousUser < User
685 685 validate :validate_anonymous_uniqueness, :on => :create
686 686
687 687 def validate_anonymous_uniqueness
688 688 # There should be only one AnonymousUser in the database
689 689 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.exists?
690 690 end
691 691
692 692 def available_custom_fields
693 693 []
694 694 end
695 695
696 696 # Overrides a few properties
697 697 def logged?; false end
698 698 def admin; false end
699 699 def name(*args); I18n.t(:label_user_anonymous) end
700 700 def mail; nil end
701 701 def time_zone; nil end
702 702 def rss_key; nil end
703 703
704 704 def pref
705 705 UserPreference.new(:user => self)
706 706 end
707 707
708 708 # Anonymous user can not be destroyed
709 709 def destroy
710 710 false
711 711 end
712 712 end
@@ -1,251 +1,251
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require '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 scope :with_updated_on, lambda { {
53 :select => "#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version",
54 :joins => "LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id"
55 } }
52 scope :with_updated_on, lambda {
53 select("#{WikiPage.table_name}.*, #{WikiContent.table_name}.updated_on, #{WikiContent.table_name}.version").
54 joins("LEFT JOIN #{WikiContent.table_name} ON #{WikiContent.table_name}.page_id = #{WikiPage.table_name}.id")
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', 'parent_title',
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 content_to = content.versions.find_by_version(version_to)
115 115 content_from = version_from ? content.versions.find_by_version(version_from.to_i) : content_to.try(:previous)
116 116 return nil unless content_to && content_from
117 117
118 118 if content_from.version > content_to.version
119 119 content_to, content_from = content_from, content_to
120 120 end
121 121
122 122 (content_to && content_from) ? WikiDiff.new(content_to, content_from) : nil
123 123 end
124 124
125 125 def annotate(version=nil)
126 126 version = version ? version.to_i : self.content.version
127 127 c = content.versions.find_by_version(version)
128 128 c ? WikiAnnotate.new(c) : nil
129 129 end
130 130
131 131 def self.pretty_title(str)
132 132 (str && str.is_a?(String)) ? str.tr('_', ' ') : str
133 133 end
134 134
135 135 def project
136 136 wiki.project
137 137 end
138 138
139 139 def text
140 140 content.text if content
141 141 end
142 142
143 143 def updated_on
144 144 unless @updated_on
145 145 if time = read_attribute(:updated_on)
146 146 # content updated_on was eager loaded with the page
147 147 begin
148 148 @updated_on = (self.class.default_timezone == :utc ? Time.parse(time.to_s).utc : Time.parse(time.to_s).localtime)
149 149 rescue
150 150 end
151 151 else
152 152 @updated_on = content && content.updated_on
153 153 end
154 154 end
155 155 @updated_on
156 156 end
157 157
158 158 # Returns true if usr is allowed to edit the page, otherwise false
159 159 def editable_by?(usr)
160 160 !protected? || usr.allowed_to?(:protect_wiki_pages, wiki.project)
161 161 end
162 162
163 163 def attachments_deletable?(usr=User.current)
164 164 editable_by?(usr) && super(usr)
165 165 end
166 166
167 167 def parent_title
168 168 @parent_title || (self.parent && self.parent.pretty_title)
169 169 end
170 170
171 171 def parent_title=(t)
172 172 @parent_title = t
173 173 parent_page = t.blank? ? nil : self.wiki.find_page(t)
174 174 self.parent = parent_page
175 175 end
176 176
177 177 # Saves the page and its content if text was changed
178 178 def save_with_content
179 179 ret = nil
180 180 transaction do
181 181 if new_record?
182 182 # Rails automatically saves associated content
183 183 ret = save
184 184 else
185 185 ret = save && (content.text_changed? ? content.save : true)
186 186 end
187 187 raise ActiveRecord::Rollback unless ret
188 188 end
189 189 ret
190 190 end
191 191
192 192 protected
193 193
194 194 def validate_parent_title
195 195 errors.add(:parent_title, :invalid) if !@parent_title.blank? && parent.nil?
196 196 errors.add(:parent_title, :circular_dependency) if parent && (parent == self || parent.ancestors.include?(self))
197 197 errors.add(:parent_title, :not_same_project) if parent && (parent.wiki_id != wiki_id)
198 198 end
199 199 end
200 200
201 201 class WikiDiff < Redmine::Helpers::Diff
202 202 attr_reader :content_to, :content_from
203 203
204 204 def initialize(content_to, content_from)
205 205 @content_to = content_to
206 206 @content_from = content_from
207 207 super(content_to.text, content_from.text)
208 208 end
209 209 end
210 210
211 211 class WikiAnnotate
212 212 attr_reader :lines, :content
213 213
214 214 def initialize(content)
215 215 @content = content
216 216 current = content
217 217 current_lines = current.text.split(/\r?\n/)
218 218 @lines = current_lines.collect {|t| [nil, nil, t]}
219 219 positions = []
220 220 current_lines.size.times {|i| positions << i}
221 221 while (current.previous)
222 222 d = current.previous.text.split(/\r?\n/).diff(current.text.split(/\r?\n/)).diffs.flatten
223 223 d.each_slice(3) do |s|
224 224 sign, line = s[0], s[1]
225 225 if sign == '+' && positions[line] && positions[line] != -1
226 226 if @lines[positions[line]][0].nil?
227 227 @lines[positions[line]][0] = current.version
228 228 @lines[positions[line]][1] = current.author
229 229 end
230 230 end
231 231 end
232 232 d.each_slice(3) do |s|
233 233 sign, line = s[0], s[1]
234 234 if sign == '-'
235 235 positions.insert(line, -1)
236 236 else
237 237 positions[line] = nil
238 238 end
239 239 end
240 240 positions.compact!
241 241 # Stop if every line is annotated
242 242 break unless @lines.detect { |line| line[0].nil? }
243 243 current = current.previous
244 244 end
245 245 @lines.each { |line|
246 246 line[0] ||= current.version
247 247 # if the last known version is > 1 (eg. history was cleared), we don't know the author
248 248 line[1] ||= current.author if current.version == 1
249 249 }
250 250 end
251 251 end
General Comments 0
You need to be logged in to leave comments. Login now