diff app/models/issue.rb @ 441:cbce1fd3b1b7 redmine-1.2

Update to Redmine 1.2-stable branch (Redmine SVN rev 6000)
author Chris Cannam
date Mon, 06 Jun 2011 14:24:13 +0100
parents 051f544170fe
children 753f1380d6bc 0c939c159af4
line wrap: on
line diff
--- a/app/models/issue.rb	Thu Mar 03 11:42:28 2011 +0000
+++ b/app/models/issue.rb	Mon Jun 06 14:24:13 2011 +0100
@@ -5,19 +5,19 @@
 # modify it under the terms of the GNU General Public License
 # as published by the Free Software Foundation; either version 2
 # of the License, or (at your option) any later version.
-# 
+#
 # This program is distributed in the hope that it will be useful,
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 # GNU General Public License for more details.
-# 
+#
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, write to the Free Software
 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 
 class Issue < ActiveRecord::Base
   include Redmine::SafeAttributes
-  
+
   belongs_to :project
   belongs_to :tracker
   belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
@@ -30,10 +30,10 @@
   has_many :journals, :as => :journalized, :dependent => :destroy
   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"
-  
+
   has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
   has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
-  
+
   acts_as_nested_set :scope => 'root_id', :dependent => :destroy
   acts_as_attachable :after_remove => :attachment_removed
   acts_as_customizable
@@ -45,7 +45,7 @@
   acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
                 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
                 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
-  
+
   acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
                             :author_key => :author_id
 
@@ -60,19 +60,14 @@
   validates_numericality_of :estimated_hours, :allow_nil => true
 
   named_scope :visible, lambda {|*args| { :include => :project,
-                                          :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
-  
+                                          :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 :for_gantt, lambda {
-    {
-      :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
-    }
-  }
 
   named_scope :without_version, lambda {
     {
@@ -90,12 +85,39 @@
   before_save :close_duplicates, :update_done_ratio_from_issue_status
   after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
   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'
+        "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id = #{user.id})"
+      when 'own'
+        "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id = #{user.id})"
+      else
+        '1=0'
+      end
+    end
+  end
+
   # 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)
+    (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 || self.assigned_to == user
+      when 'own'
+        self.author == user || self.assigned_to == user
+      else
+        false
+      end
+    end
   end
-  
+
   def after_initialize
     if new_record?
       # set default values for new records only
@@ -103,12 +125,12 @@
       self.priority ||= IssuePriority.default
     end
   end
-  
+
   # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
   def available_custom_fields
-    (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
+    (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
   end
-  
+
   def copy_from(arg)
     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")
@@ -116,7 +138,7 @@
     self.status = issue.status
     self
   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)
@@ -124,11 +146,11 @@
       move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
     end || false
   end
-  
+
   def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
     options ||= {}
     issue = options[:copy] ? self.class.new.copy_from(self) : self
-    
+
     if new_project && issue.project_id != new_project.id
       # delete issue relations
       unless Setting.cross_project_issue_relations?
@@ -153,6 +175,7 @@
       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])
@@ -165,10 +188,16 @@
       issue.attributes = options[:attributes]
     end
     if issue.save
-      unless options[:copy]
+      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
@@ -186,7 +215,7 @@
     self.status = nil
     write_attribute(:status_id, sid)
   end
-  
+
   def priority_id=(pid)
     self.priority = nil
     write_attribute(:priority_id, pid)
@@ -198,7 +227,7 @@
     @custom_field_values = nil
     result
   end
-  
+
   # Overrides attributes= so that tracker_id gets assigned first
   def attributes_with_tracker_first=(new_attributes, *args)
     return if new_attributes.nil?
@@ -210,11 +239,11 @@
   end
   # Do not redefine alias chain on reload (see #4838)
   alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
-  
+
   def estimated_hours=(h)
     write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
   end
-  
+
   safe_attributes 'tracker_id',
     'status_id',
     'parent_issue_id',
@@ -232,13 +261,19 @@
     'custom_fields',
     'lock_version',
     :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',
     :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
 
+  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))
+    }
+
   # Safely sets attributes
   # Should be called from controllers instead of #attributes=
   # attr_accessible is too rough because we still want things like
@@ -246,26 +281,26 @@
   # 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? 
-    
+    return if attrs.empty?
+
     # Tracker must be set before since new_statuses_allowed_to depends on it.
     if t = attrs.delete('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')
       end
     end
-    
+
     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')
@@ -273,10 +308,10 @@
         attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
       end
     end
-    
+
     self.attributes = attrs
   end
-  
+
   def done_ratio
     if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
       status.default_done_ratio
@@ -292,20 +327,20 @@
   def self.use_field_for_done_ratio?
     Setting.issue_done_ratio == 'issue_field'
   end
-  
+
   def validate
     if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
       errors.add :due_date, :not_a_date
     end
-    
+
     if self.due_date and self.start_date and self.due_date < self.start_date
       errors.add :due_date, :greater_than_start_date
     end
-    
+
     if start_date && soonest_start && start_date < soonest_start
       errors.add :start_date, :invalid
     end
-    
+
     if fixed_version
       if !assignable_versions.include?(fixed_version)
         errors.add :fixed_version_id, :inclusion
@@ -313,14 +348,14 @@
         errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
       end
     end
-    
+
     # Checks that the issue can not be added/moved to a disabled tracker
     if project && (tracker_id_changed? || project_id_changed?)
       unless project.trackers.include?(tracker)
         errors.add :tracker_id, :inclusion
       end
     end
-    
+
     # Checks parent issue assignment
     if @parent_issue
       if @parent_issue.project_id != project_id
@@ -337,7 +372,7 @@
       end
     end
   end
-  
+
   # Set the done_ratio using the status if that setting is set.  This will keep the done_ratios
   # even if the user turns off the setting later
   def update_done_ratio_from_issue_status
@@ -345,7 +380,7 @@
       self.done_ratio = status.default_done_ratio
     end
   end
-  
+
   def init_journal(user, notes = "")
     @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
     @issue_before_change = self.clone
@@ -356,12 +391,12 @@
     updated_on_will_change!
     @current_journal
   end
-  
+
   # Return true if the issue is closed, otherwise false
   def closed?
     self.status.is_closed?
   end
-  
+
   # Return true if the issue is being reopened
   def reopened?
     if !new_record? && status_id_changed?
@@ -385,7 +420,7 @@
     end
     false
   end
-  
+
   # Returns true if the issue is overdue
   def overdue?
     !due_date.nil? && (due_date < Date.today) && !status.is_closed?
@@ -402,24 +437,24 @@
   def children?
     !leaf?
   end
-  
+
   # Users the issue can be assigned to
   def assignable_users
     users = project.assignable_users
     users << author if author
     users.uniq.sort
   end
-  
+
   # 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
   end
-  
+
   # Returns true if this issue is blocked by another issue that is still open
   def blocked?
     !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(
@@ -433,7 +468,7 @@
     statuses = statuses.uniq.sort
     blocked? ? statuses.reject {|s| s.is_closed?} : statuses
   end
-  
+
   # Returns the mail adresses of users that should be notified
   def recipients
     notified = project.notified_users
@@ -446,7 +481,7 @@
     notified.reject! {|user| !visible?(user)}
     notified.collect(&:mail)
   end
-  
+
   # Returns the total number of hours spent on this issue and its descendants
   #
   # Example:
@@ -455,50 +490,50 @@
   def spent_hours
     @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
   end
-  
+
   def relations
     (relations_from + relations_to).sort
   end
-  
-  def all_dependent_issues(except=nil)
-    except ||= self
+
+  def all_dependent_issues(except=[])
+    except << self
     dependencies = []
     relations_from.each do |relation|
-      if relation.issue_to && relation.issue_to != except
+      if relation.issue_to && !except.include?(relation.issue_to)
         dependencies << relation.issue_to
         dependencies += relation.issue_to.all_dependent_issues(except)
       end
     end
     dependencies
   end
-  
+
   # Returns an array of issues that duplicate this one
   def duplicates
     relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
   end
-  
+
   # Returns the due date or the target due date if any
   # Used on gantt chart
   def due_before
     due_date || (fixed_version ? fixed_version.effective_date : nil)
   end
-  
+
   # Returns the time scheduled for this issue.
-  # 
+  #
   # Example:
   #   Start Date: 2/26/09, End Date: 3/04/09
   #   duration => 6
   def duration
     (start_date && due_date) ? due_date - start_date : 0
   end
-  
+
   def soonest_start
     @soonest_start ||= (
         relations_to.collect{|relation| relation.successor_soonest_start} +
         ancestors.collect(&:soonest_start)
       ).compact.max
   end
-  
+
   def reschedule_after(date)
     return if date.nil?
     if leaf?
@@ -512,7 +547,7 @@
       end
     end
   end
-  
+
   def <=>(issue)
     if issue.nil?
       -1
@@ -522,16 +557,19 @@
       (lft || 0) <=> (issue.lft || 0)
     end
   end
-  
+
   def to_s
     "#{tracker} ##{id}: #{subject}"
   end
-  
+
   # Returns a string of css classes that apply to the issue
   def css_classes
     s = "issue status-#{status.position} priority-#{priority.position}"
     s << ' closed' if closed?
     s << ' overdue' if overdue?
+    s << ' child' if child?
+    s << ' parent' unless leaf?
+    s << ' private' if is_private?
     s << ' created-by-me' if User.current.logged? && author_id == User.current.id
     s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
     s
@@ -541,7 +579,7 @@
   # Returns false if save fails
   def save_issue_with_child_records(params, existing_time_entry=nil)
     Issue.transaction do
-      if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
+      if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
         @time_entry = existing_time_entry || TimeEntry.new
         @time_entry.project = project
         @time_entry.issue = self
@@ -550,10 +588,10 @@
         @time_entry.attributes = params[:time_entry]
         self.time_entries << @time_entry
       end
-  
+
       if valid?
         attachments = Attachment.attach_files(self, params[:attachments])
-  
+
         attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
         # TODO: Rename hook
         Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
@@ -578,7 +616,7 @@
     # Update issues assigned to the version
     update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
   end
-  
+
   # Unassigns issues from versions that are no longer shared
   # after +project+ was moved
   def self.update_versions_from_hierarchy_change(project)
@@ -596,7 +634,7 @@
       nil
     end
   end
-  
+
   def parent_issue_id
     if instance_variable_defined? :@parent_issue
       @parent_issue.nil? ? nil : @parent_issue.id
@@ -645,17 +683,19 @@
   def self.by_subproject(project)
     ActiveRecord::Base.connection.select_all("select    s.id as status_id, 
                                                 s.is_closed as closed, 
-                                                i.project_id as project_id,
-                                                count(i.id) as total 
+                                                #{Issue.table_name}.project_id as project_id,
+                                                count(#{Issue.table_name}.id) as total 
                                               from 
-                                                #{Issue.table_name} i, #{IssueStatus.table_name} s
+                                                #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
                                               where 
-                                                i.status_id=s.id 
-                                                and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
-                                              group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
+                                                #{Issue.table_name}.status_id=s.id
+                                                and #{Issue.table_name}.project_id = #{Project.table_name}.id
+                                                and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
+                                                and #{Issue.table_name}.project_id <> #{project.id}
+                                              group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
   end
   # End ReportsController extraction
-  
+
   # Returns an array of projects that current user can move issues to
   def self.allowed_target_projects_on_move
     projects = []
@@ -671,9 +711,9 @@
     end
     projects
   end
-   
+
   private
-  
+
   def update_nested_set_attributes
     if root_id.nil?
       # issue was just created
@@ -720,7 +760,7 @@
     end
     remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
   end
-  
+
   def update_parent_attributes
     recalculate_attributes_for(parent_id) if parent_id
   end
@@ -731,14 +771,14 @@
       if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
         p.priority = IssuePriority.find_by_position(priority_position)
       end
-      
+
       # start/due dates = lowest/highest dates of children
       p.start_date = p.children.minimum(:start_date)
       p.due_date = p.children.maximum(:due_date)
       if p.start_date && p.due_date && p.due_date < p.start_date
         p.start_date, p.due_date = p.due_date, p.start_date
       end
-      
+
       # done ratio = weighted average ratio of leaves
       unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
         leaves_count = p.leaves.count
@@ -752,16 +792,16 @@
           p.done_ratio = progress.round
         end
       end
-      
+
       # estimate = sum of leaves estimates
       p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
       p.estimated_hours = nil if p.estimated_hours == 0.0
-      
+
       # ancestors will be recursively updated
       p.save(false)
     end
   end
-  
+
   # Update issues so their versions are not pointing to a
   # fixed_version that is not shared with the issue's project
   def self.update_versions(conditions=nil)
@@ -781,7 +821,7 @@
       end
     end
   end
-  
+
   # Callback on attachment deletion
   def attachment_removed(obj)
     journal = init_journal(User.current)
@@ -790,7 +830,7 @@
                                          :old_value => obj.filename)
     journal.save
   end
-  
+
   # Default assignment based on category
   def default_assign
     if assigned_to.nil? && category && category.assigned_to
@@ -823,7 +863,7 @@
       end
     end
   end
-  
+
   # Saves the changes in a Journal
   # Called after_save
   def create_journal
@@ -839,11 +879,11 @@
       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', 
+        @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)
-      }      
+      }
       @current_journal.save
       # reset current journal
       init_journal @current_journal.user, @current_journal.notes
@@ -862,20 +902,19 @@
     select_field = options.delete(:field)
     joins = options.delete(:joins)
 
-    where = "i.#{select_field}=j.id"
-    
+    where = "#{Issue.table_name}.#{select_field}=j.id"
+
     ActiveRecord::Base.connection.select_all("select    s.id as status_id, 
                                                 s.is_closed as closed, 
                                                 j.id as #{select_field},
-                                                count(i.id) as total 
+                                                count(#{Issue.table_name}.id) as total 
                                               from 
-                                                  #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
+                                                  #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
                                               where 
-                                                i.status_id=s.id 
+                                                #{Issue.table_name}.status_id=s.id 
                                                 and #{where}
-                                                and i.project_id=#{project.id}
+                                                and #{Issue.table_name}.project_id=#{Project.table_name}.id
+                                                and #{visible_condition(User.current, :project => project)}
                                               group by s.id, s.is_closed, j.id")
   end
-  
-
 end