Mercurial > hg > soundsoftware-site
diff app/models/issue.rb @ 1338:25603efa57b5
Merge from live branch
author | Chris Cannam |
---|---|
date | Thu, 20 Jun 2013 13:14:14 +0100 |
parents | 0a574315af3e |
children | 4f746d8966dd fb9a13467253 |
line wrap: on
line diff
--- a/app/models/issue.rb Wed Jan 23 13:11:25 2013 +0000 +++ b/app/models/issue.rb Thu Jun 20 13:14:14 2013 +0100 @@ -1,5 +1,5 @@ # Redmine - project management software -# Copyright (C) 2006-2011 Jean-Philippe Lang +# Copyright (C) 2006-2012 Jean-Philippe Lang # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -17,6 +17,7 @@ class Issue < ActiveRecord::Base include Redmine::SafeAttributes + include Redmine::Utils::DateCalculation belongs_to :project belongs_to :tracker @@ -28,6 +29,14 @@ belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id' has_many :journals, :as => :journalized, :dependent => :destroy + has_many :visible_journals, + :class_name => 'Journal', + :as => :journalized, + :conditions => Proc.new { + ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false] + }, + :readonly => true + has_many :time_entries, :dependent => :delete_all has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC" @@ -39,7 +48,7 @@ acts_as_customizable acts_as_watchable acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"], - :include => [:project, :journals], + :include => [:project, :visible_journals], # sort by id so that limited eager loading doesn't break with postgresql :order_column => "#{table_name}.id" acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"}, @@ -52,55 +61,54 @@ DONE_RATIO_OPTIONS = %w(issue_field issue_status) attr_reader :current_journal + delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true validates_presence_of :subject, :priority, :project, :tracker, :author, :status validates_length_of :subject, :maximum => 255 validates_inclusion_of :done_ratio, :in => 0..100 validates_numericality_of :estimated_hours, :allow_nil => true - validate :validate_issue + validate :validate_issue, :validate_required_fields - named_scope :visible, lambda {|*args| { :include => :project, - :conditions => Issue.visible_condition(args.shift || User.current, *args) } } + scope :visible, + lambda {|*args| { :include => :project, + :conditions => Issue.visible_condition(args.shift || User.current, *args) } } - named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status - - named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC" - named_scope :with_limit, lambda { |limit| { :limit => limit} } - named_scope :on_active_project, :include => [:status, :project, :tracker], - :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"] - - named_scope :without_version, lambda { - { - :conditions => { :fixed_version_id => nil} - } + scope :open, lambda {|*args| + is_closed = args.size > 0 ? !args.first : false + {:conditions => ["#{IssueStatus.table_name}.is_closed = ?", is_closed], :include => :status} } - named_scope :with_query, lambda {|query| - { - :conditions => Query.merge_conditions(query.statement) - } - } + scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC" + scope :on_active_project, :include => [:status, :project, :tracker], + :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"] before_create :default_assign - before_save :close_duplicates, :update_done_ratio_from_issue_status + before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change + after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?} after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal + # Should be after_create but would be called before previous after_save callbacks + after_save :after_create_from_copy after_destroy :update_parent_attributes # Returns a SQL conditions string used to find all issues visible by the specified user def self.visible_condition(user, options={}) Project.allowed_to_condition(user, :view_issues, options) do |role, user| - case role.issues_visibility - when 'all' - nil - when 'default' - user_ids = [user.id] + user.groups.map(&:id) - "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))" - when 'own' - user_ids = [user.id] + user.groups.map(&:id) - "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))" + if user.logged? + case role.issues_visibility + when 'all' + nil + when 'default' + user_ids = [user.id] + user.groups.map(&:id) + "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))" + when 'own' + user_ids = [user.id] + user.groups.map(&:id) + "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))" + else + '1=0' + end else - '1=0' + "(#{table_name}.is_private = #{connection.quoted_false})" end end end @@ -108,115 +116,127 @@ # Returns true if usr or current user is allowed to view the issue def visible?(usr=nil) (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user| - case role.issues_visibility - when 'all' - true - when 'default' - !self.is_private? || self.author == user || user.is_or_belongs_to?(assigned_to) - when 'own' - self.author == user || user.is_or_belongs_to?(assigned_to) + if user.logged? + case role.issues_visibility + when 'all' + true + when 'default' + !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to)) + when 'own' + self.author == user || user.is_or_belongs_to?(assigned_to) + else + false + end else - false + !self.is_private? end end end - def after_initialize + def initialize(attributes=nil, *args) + super if new_record? # set default values for new records only self.status ||= IssueStatus.default self.priority ||= IssuePriority.default + self.watcher_user_ids = [] end end + # AR#Persistence#destroy would raise and RecordNotFound exception + # if the issue was already deleted or updated (non matching lock_version). + # This is a problem when bulk deleting issues or deleting a project + # (because an issue may already be deleted if its parent was deleted + # first). + # The issue is reloaded by the nested_set before being deleted so + # the lock_version condition should not be an issue but we handle it. + def destroy + super + rescue ActiveRecord::RecordNotFound + # Stale or already deleted + begin + reload + rescue ActiveRecord::RecordNotFound + # The issue was actually already deleted + @destroyed = true + return freeze + end + # The issue was stale, retry to destroy + super + end + + def reload(*args) + @workflow_rule_by_attribute = nil + @assignable_versions = nil + super + end + # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields def available_custom_fields (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : [] end - def copy_from(arg) + # Copies attributes from another issue, arg can be an id or an Issue + def copy_from(arg, options={}) issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg) self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on") self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h} self.status = issue.status + self.author = User.current + unless options[:attachments] == false + self.attachments = issue.attachments.map do |attachement| + attachement.copy(:container => self) + end + end + @copied_from = issue + @copy_options = options self end + # Returns an unsaved copy of the issue + def copy(attributes=nil, copy_options={}) + copy = self.class.new.copy_from(self, copy_options) + copy.attributes = attributes if attributes + copy + end + + # Returns true if the issue is a copy + def copy? + @copied_from.present? + end + # Moves/copies an issue to a new project and tracker # Returns the moved/copied issue on success, false on failure - def move_to_project(*args) - ret = Issue.transaction do - move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback) - end || false - end + def move_to_project(new_project, new_tracker=nil, options={}) + ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead." - def move_to_project_without_transaction(new_project, new_tracker = nil, options = {}) - options ||= {} - issue = options[:copy] ? self.class.new.copy_from(self) : self + if options[:copy] + issue = self.copy + else + issue = self + end - if new_project && issue.project_id != new_project.id - # delete issue relations - unless Setting.cross_project_issue_relations? - issue.relations_from.clear - issue.relations_to.clear - end - # issue is moved to another project - # reassign to the category with same name if any - new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name) - issue.category = new_category - # Keep the fixed_version if it's still valid in the new_project - unless new_project.shared_versions.include?(issue.fixed_version) - issue.fixed_version = nil - end - issue.project = new_project - if issue.parent && issue.parent.project_id != issue.project_id - issue.parent_issue_id = nil - end - end + issue.init_journal(User.current, options[:notes]) + + # Preserve previous behaviour + # #move_to_project doesn't change tracker automatically + issue.send :project=, new_project, true if new_tracker issue.tracker = new_tracker - issue.reset_custom_values! - end - if options[:copy] - issue.author = User.current - issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h} - issue.status = if options[:attributes] && options[:attributes][:status_id] - IssueStatus.find_by_id(options[:attributes][:status_id]) - else - self.status - end end # Allow bulk setting of attributes on the issue if options[:attributes] issue.attributes = options[:attributes] end - if issue.save - if options[:copy] - if current_journal && current_journal.notes.present? - issue.init_journal(current_journal.user, current_journal.notes) - issue.current_journal.notify = false - issue.save - end - else - # Manually update project_id on related time entries - TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id}) - issue.children.each do |child| - unless child.move_to_project_without_transaction(new_project) - # Move failed and transaction was rollback'd - return false - end - end - end - else - return false - end - issue + issue.save ? issue : false end def status_id=(sid) self.status = nil - write_attribute(:status_id, sid) + result = write_attribute(:status_id, sid) + @workflow_rule_by_attribute = nil + result end def priority_id=(pid) @@ -224,13 +244,56 @@ write_attribute(:priority_id, pid) end + def category_id=(cid) + self.category = nil + write_attribute(:category_id, cid) + end + + def fixed_version_id=(vid) + self.fixed_version = nil + write_attribute(:fixed_version_id, vid) + end + def tracker_id=(tid) self.tracker = nil result = write_attribute(:tracker_id, tid) @custom_field_values = nil + @workflow_rule_by_attribute = nil result end + def project_id=(project_id) + if project_id.to_s != self.project_id.to_s + self.project = (project_id.present? ? Project.find_by_id(project_id) : nil) + end + end + + def project=(project, keep_tracker=false) + project_was = self.project + write_attribute(:project_id, project ? project.id : nil) + association_instance_set('project', project) + if project_was && project && project_was != project + @assignable_versions = nil + + unless keep_tracker || project.trackers.include?(tracker) + self.tracker = project.trackers.first + end + # Reassign to the category with same name if any + if category + self.category = project.issue_categories.find_by_name(category.name) + end + # Keep the fixed_version if it's still valid in the new_project + if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version) + self.fixed_version = nil + end + # Clear the parent task if it's no longer valid + unless valid_parent_project? + self.parent_issue_id = nil + end + @custom_field_values = nil + end + end + def description=(arg) if arg.is_a?(String) arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n") @@ -238,25 +301,38 @@ write_attribute(:description, arg) end - # Overrides attributes= so that tracker_id gets assigned first - def attributes_with_tracker_first=(new_attributes, *args) + # Overrides assign_attributes so that project and tracker get assigned first + def assign_attributes_with_project_and_tracker_first(new_attributes, *args) return if new_attributes.nil? - new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id] - if new_tracker_id - self.tracker_id = new_tracker_id + attrs = new_attributes.dup + attrs.stringify_keys! + + %w(project project_id tracker tracker_id).each do |attr| + if attrs.has_key?(attr) + send "#{attr}=", attrs.delete(attr) + end end - send :attributes_without_tracker_first=, new_attributes, *args + send :assign_attributes_without_project_and_tracker_first, attrs, *args end # Do not redefine alias chain on reload (see #4838) - alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=) + alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first) def estimated_hours=(h) write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h) end + safe_attributes 'project_id', + :if => lambda {|issue, user| + if issue.new_record? + issue.copy? + elsif user.allowed_to?(:move_issues, issue.project) + projects = Issue.allowed_target_projects_on_move(user) + projects.include?(issue.project) && projects.size > 1 + end + } + safe_attributes 'tracker_id', 'status_id', - 'parent_issue_id', 'category_id', 'assigned_to_id', 'priority_id', @@ -270,58 +346,169 @@ 'custom_field_values', 'custom_fields', 'lock_version', + 'notes', :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) } safe_attributes 'status_id', 'assigned_to_id', 'fixed_version_id', 'done_ratio', + 'lock_version', + 'notes', :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? } + safe_attributes 'notes', + :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)} + + safe_attributes 'private_notes', + :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)} + + safe_attributes 'watcher_user_ids', + :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)} + safe_attributes 'is_private', :if => lambda {|issue, user| user.allowed_to?(:set_issues_private, issue.project) || (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project)) } + safe_attributes 'parent_issue_id', + :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) && + user.allowed_to?(:manage_subtasks, issue.project)} + + def safe_attribute_names(user=nil) + names = super + names -= disabled_core_fields + names -= read_only_attribute_names(user) + names + end + # Safely sets attributes # Should be called from controllers instead of #attributes= # attr_accessible is too rough because we still want things like # Issue.new(:project => foo) to work - # TODO: move workflow/permission checks from controllers to here def safe_attributes=(attrs, user=User.current) return unless attrs.is_a?(Hash) - # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed - attrs = delete_unsafe_attributes(attrs, user) - return if attrs.empty? + attrs = attrs.dup - # Tracker must be set before since new_statuses_allowed_to depends on it. - if t = attrs.delete('tracker_id') + # Project and Tracker must be set before since new_statuses_allowed_to depends on it. + if (p = attrs.delete('project_id')) && safe_attribute?('project_id') + if allowed_target_projects(user).collect(&:id).include?(p.to_i) + self.project_id = p + end + end + + if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id') self.tracker_id = t end - if attrs['status_id'] - unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i) - attrs.delete('status_id') + if (s = attrs.delete('status_id')) && safe_attribute?('status_id') + if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i) + self.status_id = s end end + attrs = delete_unsafe_attributes(attrs, user) + return if attrs.empty? + unless leaf? attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)} end - if attrs.has_key?('parent_issue_id') - if !user.allowed_to?(:manage_subtasks, project) - attrs.delete('parent_issue_id') - elsif !attrs['parent_issue_id'].blank? - attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i) + if attrs['parent_issue_id'].present? + s = attrs['parent_issue_id'].to_s + unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1])) + @invalid_parent_issue_id = attrs.delete('parent_issue_id') end end - self.attributes = attrs + if attrs['custom_field_values'].present? + attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s} + end + + if attrs['custom_fields'].present? + attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s} + end + + # mass-assignment security bypass + assign_attributes attrs, :without_protection => true end + def disabled_core_fields + tracker ? tracker.disabled_core_fields : [] + end + + # Returns the custom_field_values that can be edited by the given user + def editable_custom_field_values(user=nil) + custom_field_values.reject do |value| + read_only_attribute_names(user).include?(value.custom_field_id.to_s) + end + end + + # Returns the names of attributes that are read-only for user or the current user + # For users with multiple roles, the read-only fields are the intersection of + # read-only fields of each role + # The result is an array of strings where sustom fields are represented with their ids + # + # Examples: + # issue.read_only_attribute_names # => ['due_date', '2'] + # issue.read_only_attribute_names(user) # => [] + def read_only_attribute_names(user=nil) + workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys + end + + # Returns the names of required attributes for user or the current user + # For users with multiple roles, the required fields are the intersection of + # required fields of each role + # The result is an array of strings where sustom fields are represented with their ids + # + # Examples: + # issue.required_attribute_names # => ['due_date', '2'] + # issue.required_attribute_names(user) # => [] + def required_attribute_names(user=nil) + workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys + end + + # Returns true if the attribute is required for user + def required_attribute?(name, user=nil) + required_attribute_names(user).include?(name.to_s) + end + + # Returns a hash of the workflow rule by attribute for the given user + # + # Examples: + # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'} + def workflow_rule_by_attribute(user=nil) + return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil? + + user_real = user || User.current + roles = user_real.admin ? Role.all : user_real.roles_for_project(project) + return {} if roles.empty? + + result = {} + workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all + if workflow_permissions.any? + workflow_rules = workflow_permissions.inject({}) do |h, wp| + h[wp.field_name] ||= [] + h[wp.field_name] << wp.rule + h + end + workflow_rules.each do |attr, rules| + next if rules.size < roles.size + uniq_rules = rules.uniq + if uniq_rules.size == 1 + result[attr] = uniq_rules.first + else + result[attr] = 'required' + end + end + end + @workflow_rule_by_attribute = result if user.nil? + result + end + private :workflow_rule_by_attribute + def done_ratio if Issue.use_status_for_done_ratio? && status && status.default_done_ratio status.default_done_ratio @@ -339,11 +526,15 @@ end def validate_issue - if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty? + if due_date.nil? && @attributes['due_date'].present? errors.add :due_date, :not_a_date end - if self.due_date and self.start_date and self.due_date < self.start_date + if start_date.nil? && @attributes['start_date'].present? + errors.add :start_date, :not_a_date + end + + if due_date && start_date && due_date < start_date errors.add :due_date, :greater_than_start_date end @@ -367,9 +558,11 @@ end # Checks parent issue assignment - if @parent_issue - if @parent_issue.project_id != project_id - errors.add :parent_issue_id, :not_same_project + if @invalid_parent_issue_id.present? + errors.add :parent_issue_id, :invalid + elsif @parent_issue + if !valid_parent_project?(@parent_issue) + errors.add :parent_issue_id, :invalid elsif !new_record? # moving an existing issue if @parent_issue.root_id != root_id @@ -377,7 +570,26 @@ elsif move_possible?(@parent_issue) # move accepted inside tree else - errors.add :parent_issue_id, :not_a_valid_parent + errors.add :parent_issue_id, :invalid + end + end + end + end + + # Validates the issue against additional workflow requirements + def validate_required_fields + user = new_record? ? author : current_journal.try(:user) + + required_attribute_names(user).each do |attribute| + if attribute =~ /^\d+$/ + attribute = attribute.to_i + v = custom_field_values.detect {|v| v.custom_field_id == attribute } + if v && v.value.blank? + errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank') + end + else + if respond_to?(attribute) && send(attribute).blank? + errors.add attribute, :blank end end end @@ -393,15 +605,34 @@ def init_journal(user, notes = "") @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes) - @issue_before_change = self.clone - @issue_before_change.status = self.status - @custom_values_before_change = {} - self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value } - # Make sure updated_on is updated when adding a note. - updated_on_will_change! + if new_record? + @current_journal.notify = false + else + @attributes_before_change = attributes.dup + @custom_values_before_change = {} + self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value } + end @current_journal end + # Returns the id of the last journal or nil + def last_journal_id + if new_record? + nil + else + journals.maximum(:id) + end + end + + # Returns a scope for journals that have an id greater than journal_id + def journals_after(journal_id) + scope = journals.reorder("#{Journal.table_name}.id ASC") + if journal_id.present? + scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i) + end + scope + end + # Return true if the issue is closed, otherwise false def closed? self.status.is_closed? @@ -458,7 +689,21 @@ # Versions that the issue can be assigned to def assignable_versions - @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort + return @assignable_versions if @assignable_versions + + versions = project.shared_versions.open.all + if fixed_version + if fixed_version_id_changed? + # nothing to do + elsif project_id_changed? + if project.shared_versions.include?(fixed_version) + versions << fixed_version + end + else + versions << fixed_version + end + end + @assignable_versions = versions.uniq.sort end # Returns true if this issue is blocked by another issue that is still open @@ -466,37 +711,67 @@ !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil? end - # Returns an array of status that user is able to apply - def new_statuses_allowed_to(user, include_default=false) - statuses = status.find_new_statuses_allowed_to( - user.roles_for_project(project), - tracker, - author == user, - assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id - ) - statuses << status unless statuses.empty? - statuses << IssueStatus.default if include_default - statuses = statuses.uniq.sort - blocked? ? statuses.reject {|s| s.is_closed?} : statuses + # Returns an array of statuses that user is able to apply + def new_statuses_allowed_to(user=User.current, include_default=false) + if new_record? && @copied_from + [IssueStatus.default, @copied_from.status].compact.uniq.sort + else + initial_status = nil + if new_record? + initial_status = IssueStatus.default + elsif status_id_was + initial_status = IssueStatus.find_by_id(status_id_was) + end + initial_status ||= status + + statuses = initial_status.find_new_statuses_allowed_to( + user.admin ? Role.all : user.roles_for_project(project), + tracker, + author == user, + assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id + ) + statuses << initial_status unless statuses.empty? + statuses << IssueStatus.default if include_default + statuses = statuses.compact.uniq.sort + blocked? ? statuses.reject {|s| s.is_closed?} : statuses + end end - # Returns the mail adresses of users that should be notified - def recipients - notified = project.notified_users + def assigned_to_was + if assigned_to_id_changed? && assigned_to_id_was.present? + @assigned_to_was ||= User.find_by_id(assigned_to_id_was) + end + end + + # Returns the users that should be notified + def notified_users + notified = [] # Author and assignee are always notified unless they have been # locked or don't want to be notified - notified << author if author && author.active? && author.notify_about?(self) + notified << author if author if assigned_to - if assigned_to.is_a?(Group) - notified += assigned_to.users.select {|u| u.active? && u.notify_about?(self)} - else - notified << assigned_to if assigned_to.active? && assigned_to.notify_about?(self) - end + notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to]) end + if assigned_to_was + notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was]) + end + notified = notified.select {|u| u.active? && u.notify_about?(self)} + + notified += project.notified_users notified.uniq! # Remove users that can not view the issue notified.reject! {|user| !visible?(user)} - notified.collect(&:mail) + notified + end + + # Returns the email addresses that should be notified + def recipients + notified_users.collect(&:mail) + end + + # Returns the number of hours spent on this issue + def spent_hours + @spent_hours ||= time_entries.sum(:hours) || 0 end # Returns the total number of hours spent on this issue and its descendants @@ -504,12 +779,13 @@ # Example: # spent_hours => 0.0 # spent_hours => 50.2 - def spent_hours - @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0 + def total_spent_hours + @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", + :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0 end def relations - @relations ||= (relations_from + relations_to).sort + @relations ||= IssueRelations.new(self, (relations_from + relations_to).sort) end # Preloads relations for a collection of issues @@ -522,6 +798,35 @@ end end + # Preloads visible spent time for a collection of issues + def self.load_visible_spent_hours(issues, user=User.current) + if issues.any? + hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id) + issues.each do |issue| + issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0) + end + end + end + + # Preloads visible relations for a collection of issues + def self.load_visible_relations(issues, user=User.current) + if issues.any? + issue_ids = issues.map(&:id) + # Relations with issue_from in given issues and visible issue_to + relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all + # Relations with issue_to in given issues and visible issue_from + relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all + + issues.each do |issue| + relations = + relations_from.select {|relation| relation.issue_from_id == issue.id} + + relations_to.select {|relation| relation.issue_to_id == issue.id} + + issue.instance_variable_set "@relations", IssueRelations.new(issue, relations.sort) + end + end + end + # Finds an issue relation given its id. def find_relation(relation_id) IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id]) @@ -559,23 +864,58 @@ (start_date && due_date) ? due_date - start_date : 0 end - def soonest_start + # Returns the duration in working days + def working_duration + (start_date && due_date) ? working_days(start_date, due_date) : 0 + end + + def soonest_start(reload=false) + @soonest_start = nil if reload @soonest_start ||= ( - relations_to.collect{|relation| relation.successor_soonest_start} + + relations_to(reload).collect{|relation| relation.successor_soonest_start} + ancestors.collect(&:soonest_start) ).compact.max end - def reschedule_after(date) + # Sets start_date on the given date or the next working day + # and changes due_date to keep the same working duration. + def reschedule_on(date) + wd = working_duration + date = next_working_date(date) + self.start_date = date + self.due_date = add_working_days(date, wd) + end + + # Reschedules the issue on the given date or the next working day and saves the record. + # If the issue is a parent task, this is done by rescheduling its subtasks. + def reschedule_on!(date) return if date.nil? if leaf? - if start_date.nil? || start_date < date - self.start_date, self.due_date = date, date + duration - save + if start_date.nil? || start_date != date + if start_date && start_date > date + # Issue can not be moved earlier than its soonest start date + date = [soonest_start(true), date].compact.max + end + reschedule_on(date) + begin + save + rescue ActiveRecord::StaleObjectError + reload + reschedule_on(date) + save + end end else leaves.each do |leaf| - leaf.reschedule_after(date) + if leaf.start_date + # Only move subtask if it starts at the same date as the parent + # or if it starts before the given date + if start_date == leaf.start_date || date > leaf.start_date + leaf.reschedule_on!(date) + end + else + leaf.reschedule_on!(date) + end end end end @@ -596,8 +936,7 @@ # Returns a string of css classes that apply to the issue def css_classes - s = "issue status-#{status.position} " - s << "priority-#{priority.position}" + s = "issue status-#{status_id} #{priority.try(:css_classes)}" s << ' closed' if closed? s << ' overdue' if overdue? s << ' child' if child? @@ -608,8 +947,7 @@ s end - # Saves an issue, time_entry, attachments, and a journal from the parameters - # Returns false if save fails + # Saves an issue and a time_entry from the parameters def save_issue_with_child_records(params, existing_time_entry=nil) Issue.transaction do if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project) @@ -622,22 +960,13 @@ self.time_entries << @time_entry end - if valid? - attachments = Attachment.attach_files(self, params[:attachments]) + # TODO: Rename hook + Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal}) + if save # TODO: Rename hook - Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal}) - begin - if save - # TODO: Rename hook - Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal}) - else - raise ActiveRecord::Rollback - end - rescue ActiveRecord::StaleObjectError - attachments[:files].each(&:destroy) - errors.add :base, l(:notice_locking_conflict) - raise ActiveRecord::Rollback - end + Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal}) + else + raise ActiveRecord::Rollback end end end @@ -657,23 +986,44 @@ end def parent_issue_id=(arg) - parent_issue_id = arg.blank? ? nil : arg.to_i - if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id) + s = arg.to_s.strip.presence + if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1])) @parent_issue.id else @parent_issue = nil - nil + @invalid_parent_issue_id = arg end end def parent_issue_id - if instance_variable_defined? :@parent_issue + if @invalid_parent_issue_id + @invalid_parent_issue_id + elsif instance_variable_defined? :@parent_issue @parent_issue.nil? ? nil : @parent_issue.id else parent_id end end + # Returns true if issue's project is a valid + # parent issue project + def valid_parent_project?(issue=parent) + return true if issue.nil? || issue.project_id == project_id + + case Setting.cross_project_subtasks + when 'system' + true + when 'tree' + issue.project.root == project.root + when 'hierarchy' + issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project) + when 'descendants' + issue.project.is_or_is_ancestor_of?(project) + else + false + end + end + # Extracted from the ReportsController. def self.by_tracker(project) count_and_group_by(:project => project, @@ -727,24 +1077,76 @@ end # End ReportsController extraction - # Returns an array of projects that current user can move issues to - def self.allowed_target_projects_on_move - projects = [] - if User.current.admin? - # admin is allowed to move issues to any active (visible) project - projects = Project.visible.all - elsif User.current.logged? - if Role.non_member.allowed_to?(:move_issues) - projects = Project.visible.all - else - User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}} - end + # Returns an array of projects that user can assign the issue to + def allowed_target_projects(user=User.current) + if new_record? + Project.all(:conditions => Project.allowed_to_condition(user, :add_issues)) + else + self.class.allowed_target_projects_on_move(user) end - projects + end + + # Returns an array of projects that user can move issues to + def self.allowed_target_projects_on_move(user=User.current) + Project.all(:conditions => Project.allowed_to_condition(user, :move_issues)) end private + def after_project_change + # Update project_id on related time entries + TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id}) + + # Delete issue relations + unless Setting.cross_project_issue_relations? + relations_from.clear + relations_to.clear + end + + # Move subtasks that were in the same project + children.each do |child| + next unless child.project_id == project_id_was + # Change project and keep project + child.send :project=, project, true + unless child.save + raise ActiveRecord::Rollback + end + end + end + + # Callback for after the creation of an issue by copy + # * adds a "copied to" relation with the copied issue + # * copies subtasks from the copied issue + def after_create_from_copy + return unless copy? && !@after_create_from_copy_handled + + if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false + relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO) + unless relation.save + logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger + end + end + + unless @copied_from.leaf? || @copy_options[:subtasks] == false + @copied_from.children.each do |child| + unless child.visible? + # Do not copy subtasks that are not visible to avoid potential disclosure of private data + logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger + next + end + copy = Issue.new.copy_from(child, @copy_options) + copy.author = author + copy.project = project + copy.parent_issue_id = id + # Children subtasks are copied recursively + unless copy.save + 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 + end + end + end + @after_create_from_copy_handled = true + end + def update_nested_set_attributes if root_id.nil? # issue was just created @@ -799,7 +1201,7 @@ def recalculate_attributes_for(issue_id) if issue_id && p = Issue.find_by_id(issue_id) # priority = highest priority of children - if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority) + if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority) p.priority = IssuePriority.find_by_position(priority_position) end @@ -818,7 +1220,7 @@ if average == 0 average = 1 end - done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f + 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 progress = done / (average * leaves_count) p.done_ratio = progress.round end @@ -829,7 +1231,7 @@ p.estimated_hours = nil if p.estimated_hours == 0.0 # ancestors will be recursively updated - p.save(false) + p.save(:validate => false) end end @@ -838,12 +1240,12 @@ def self.update_versions(conditions=nil) # Only need to update issues with a fixed_version from # a different project and that is not systemwide shared - Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" + - " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" + - " AND #{Version.table_name}.sharing <> 'system'", - conditions), - :include => [:project, :fixed_version] - ).each do |issue| + Issue.scoped(:conditions => conditions).all( + :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" + + " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" + + " AND #{Version.table_name}.sharing <> 'system'", + :include => [:project, :fixed_version] + ).each do |issue| next if issue.project.nil? || issue.fixed_version.nil? unless issue.project.shared_versions.include?(issue.fixed_version) issue.init_journal(User.current) @@ -853,7 +1255,7 @@ end end - # Callback on attachment deletion + # Callback on file attachment def attachment_added(obj) if @current_journal && !obj.new_record? @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename) @@ -862,11 +1264,10 @@ # Callback on attachment deletion def attachment_removed(obj) - journal = init_journal(User.current) - journal.details << JournalDetail.new(:property => 'attachment', - :prop_key => obj.id, - :old_value => obj.filename) - journal.save + if @current_journal && !obj.new_record? + @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename) + @current_journal.save + end end # Default assignment based on category @@ -902,29 +1303,62 @@ end end + # Make sure updated_on is updated when adding a note + def force_updated_on_change + if @current_journal + self.updated_on = current_time_from_proper_timezone + end + end + # Saves the changes in a Journal # Called after_save def create_journal if @current_journal # attributes changes - (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c| - before = @issue_before_change.send(c) - after = send(c) - next if before == after || (before.blank? && after.blank?) - @current_journal.details << JournalDetail.new(:property => 'attr', - :prop_key => c, - :old_value => @issue_before_change.send(c), - :value => send(c)) - } - # custom fields changes - custom_values.each {|c| - next if (@custom_values_before_change[c.custom_field_id]==c.value || - (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?)) - @current_journal.details << JournalDetail.new(:property => 'cf', - :prop_key => c.custom_field_id, - :old_value => @custom_values_before_change[c.custom_field_id], - :value => c.value) - } + if @attributes_before_change + (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c| + before = @attributes_before_change[c] + after = send(c) + next if before == after || (before.blank? && after.blank?) + @current_journal.details << JournalDetail.new(:property => 'attr', + :prop_key => c, + :old_value => before, + :value => after) + } + end + if @custom_values_before_change + # custom fields changes + custom_field_values.each {|c| + before = @custom_values_before_change[c.custom_field_id] + after = c.value + next if before == after || (before.blank? && after.blank?) + + if before.is_a?(Array) || after.is_a?(Array) + before = [before] unless before.is_a?(Array) + after = [after] unless after.is_a?(Array) + + # values removed + (before - after).reject(&:blank?).each do |value| + @current_journal.details << JournalDetail.new(:property => 'cf', + :prop_key => c.custom_field_id, + :old_value => value, + :value => nil) + end + # values added + (after - before).reject(&:blank?).each do |value| + @current_journal.details << JournalDetail.new(:property => 'cf', + :prop_key => c.custom_field_id, + :old_value => nil, + :value => value) + end + else + @current_journal.details << JournalDetail.new(:property => 'cf', + :prop_key => c.custom_field_id, + :old_value => before, + :value => after) + end + } + end @current_journal.save # reset current journal init_journal @current_journal.user, @current_journal.notes