The Refactoring

This refactoring is what I call the magic trick refactor; it looks like a lot is going on but in reality it's just smoke and mirrors!

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def update
    update_issue_from_params

    if request.get?
      # nop
    else
      @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
      @time_entry.attributes = params[:time_entry]
      if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
        attachments = attach_files(@issue, params[:attachments])
        attachments.each {|a| @journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
        call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => @journal})
        if @issue.save
          # Log spend time
          if User.current.allowed_to?(:log_time, @project)
            @time_entry.save
          end
          if !@journal.new_record?
            # Only send notification if something was actually changed
            flash[:notice] = l(:notice_successful_update)
          end
          call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => @journal})
          respond_to do |format|
            format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
            format.xml  { head :ok }
          end
          return
        end
      end
      # failure
      respond_to do |format|
        format.html { render :action => 'edit' }
        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
      end
    end
  rescue ActiveRecord::StaleObjectError
    # Optimistic locking exception
    flash.now[:error] = l(:notice_locking_conflict)
    # Remove the previously added attachments if issue was not updated
    attachments.each(&:destroy)
  end

  end
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def update
    update_issue_from_params

    if issue_update
      respond_to do |format|
        format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
        format.xml  { head :ok }
      end
    else
      respond_to do |format|
        format.html { render :action => 'edit' }
        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
      end
    end
    
  rescue ActiveRecord::StaleObjectError
    # Optimistic locking exception
    flash.now[:error] = l(:notice_locking_conflict)
    # Remove the previously added attachments if issue was not updated
    attachments.each(&:destroy)
  end

  # ...

  # TODO: Temporary utility method for #update.  Should be split off
  # and moved to the Issue model (accepts_nested_attributes_for maybe?)
  # TODO: move attach_files to the model so this can be moved to the
  # model also
  def issue_update
    @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
    @time_entry.attributes = params[:time_entry]
    if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
      attachments = attach_files(@issue, params[:attachments])
      attachments.each {|a| @journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
      call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => @journal})
      if @issue.save
        # Log spend time
        if User.current.allowed_to?(:log_time, @project)
          @time_entry.save
        end
        if !@journal.new_record?
          # Only send notification if something was actually changed
          flash[:notice] = l(:notice_successful_update)
        end
        call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => @journal})
        return true
      end
    end
    # failure, returns false

  end
end

Review

Truthfully, this refactor isn't just smoke and mirrors. The behavior stayed the same but now #update is easier to read and is working at a higher level of abstraction (e.g. only handling the response). The #issue_update method is still pretty smelly but it's just about ripe for some refactoring.

Reference commit

Tagged: ruby redmine refactoring extract-method

I tackled two refactorings today in order to clean up IssuesController#edit.

The Refactoring - #1

This first refactoring moves most of the #edit method's body into #update, where it belongs. This did cause a some duplication though.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def edit
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
    @priorities = IssuePriority.all
    @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
    @time_entry = TimeEntry.new
    
    @notes = params[:notes]
    journal = @issue.init_journal(User.current, @notes)
    # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
    if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
      attrs = params[:issue].dup
      attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
      attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
      @issue.safe_attributes = attrs
    end

    if request.get?
      # nop
    else
      @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
      @time_entry.attributes = params[:time_entry]
      if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
        attachments = attach_files(@issue, params[:attachments])
        attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
        call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
        if @issue.save
          # Log spend time
          if User.current.allowed_to?(:log_time, @project)
            @time_entry.save
          end
          if !journal.new_record?
            # Only send notification if something was actually changed
            flash[:notice] = l(:notice_successful_update)
          end
          call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
          respond_to do |format|
            format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
            format.xml  { head :ok }
          end
          return
        end
      end
      # failure
      respond_to do |format|
        format.html { render :action => 'edit' }
        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
      end
    end
  rescue ActiveRecord::StaleObjectError
    # Optimistic locking exception
    flash.now[:error] = l(:notice_locking_conflict)
    # Remove the previously added attachments if issue was not updated
    attachments.each(&:destroy)
  end

  def update
    edit
  end
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def edit
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
    @priorities = IssuePriority.all
    @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
    @time_entry = TimeEntry.new
    
    @notes = params[:notes]
    journal = @issue.init_journal(User.current, @notes)
    # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
    if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
      attrs = params[:issue].dup
      attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
      attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
      @issue.safe_attributes = attrs
    end

    respond_to do |format|
      format.html { }
      format.xml  { }
    end
  end

  def update
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
    @priorities = IssuePriority.all
    @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
    @time_entry = TimeEntry.new
    
    @notes = params[:notes]
    journal = @issue.init_journal(User.current, @notes)
    # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
    if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
      attrs = params[:issue].dup
      attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
      attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
      @issue.safe_attributes = attrs
    end

    if request.get?
      # nop
    else
      @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
      @time_entry.attributes = params[:time_entry]
      if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
        attachments = attach_files(@issue, params[:attachments])
        attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
        call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
        if @issue.save
          # Log spend time
          if User.current.allowed_to?(:log_time, @project)
            @time_entry.save
          end
          if !journal.new_record?
            # Only send notification if something was actually changed
            flash[:notice] = l(:notice_successful_update)
          end
          call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
          respond_to do |format|
            format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
            format.xml  { head :ok }
          end
          return
        end
      end
      # failure
      respond_to do |format|
        format.html { render :action => 'edit' }
        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
      end
    end
  rescue ActiveRecord::StaleObjectError
    # Optimistic locking exception
    flash.now[:error] = l(:notice_locking_conflict)
    # Remove the previously added attachments if issue was not updated
    attachments.each(&:destroy)
  end
end

The Refactoring - #2

This refactoring used extract method to pull the duplication out of #edit and #update into a utility method.

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def edit
    update_issue_from_params

    respond_to do |format|
      format.html { }
      format.xml  { }
    end
  end

  def update
    update_issue_from_params

    if request.get?
      # nop
    else
      @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
      @time_entry.attributes = params[:time_entry]
      if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
        # ... saving code
      end
      # failure
      respond_to do |format|
        format.html { render :action => 'edit' }
        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
      end
    end
  rescue ActiveRecord::StaleObjectError
    # Optimistic locking exception
    flash.now[:error] = l(:notice_locking_conflict)
    # Remove the previously added attachments if issue was not updated
    attachments.each(&:destroy)
  end

  #...
  def update_issue_from_params
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
    @priorities = IssuePriority.all
    @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
    @time_entry = TimeEntry.new
    
    @notes = params[:notes]
    @journal = @issue.init_journal(User.current, @notes)
    # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
    if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
      attrs = params[:issue].dup
      attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
      attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
      @issue.safe_attributes = attrs
    end

  end
end

Review

Now that IssuesController is following the REST design for it's edit and update actions, the next refactorings on them will be to clean up their internal implementation. #edit looks really well factored already, but #update can use some more, especially inside of it's main if conditional. And #update_issue_from_params is doing quite a bit of work, especially for the read-only #edit action.

Reference commits

Tagged: ruby redmine refactoring extract-method-body

The 0.5.0 release of the Redmine Bulk Time Entry plugin has just been uploaded to my Redmine, Github, and gemcutter. This is a bug fix release for Redmine 0.9 only.

Changes

Help

If you need help, my Redmine bug tracker is open to the public and you are welcome to ask for help there.

Eric

Tagged: open source redmine redmine plugins

After yesterday's refactor of the #bulk_edit method, Jean-Philippe Lang finished the refactoring by merging the temporary method I created in the model (Issue#bulk_edit) and simplifying the parameter attributes.

Back to the drawing board, it looks like the method with the next highest flog score is #edit. #edit is a pretty complex action in Redmine, since it serves as both rendering the edit form and saving the edits of issues and time entries. It flows like this:

def edit
  if allowed to change issue
    set issue attribues
  end

  if GET request
    render form
  else
    if time entry is valid
      attach file uploads
      if issue saved
        save time entry
        redirect to issue
      end
    end
    rerender form
  end

  rescue stale object updates
    render form
  end
end  

Just from that psuedocode, it's easy to see how many different responsibilities that action has. To follow the standard REST design it should be two actions; one for rendering the form (#edit) and one for processing the update (#update).

The Refactoring

This refactoring is the first step towards decouping #edit into two actions.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def edit
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
    @priorities = IssuePriority.all
    @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
    @time_entry = TimeEntry.new
    
    @notes = params[:notes]
    journal = @issue.init_journal(User.current, @notes)
    # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
    if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
      attrs = params[:issue].dup
      attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
      attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
      @issue.safe_attributes = attrs
    end

    if request.get?
      # nop
    else
      @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
      @time_entry.attributes = params[:time_entry]
      if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
        attachments = attach_files(@issue, params[:attachments])
        attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
        call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
        if @issue.save
          # Log spend time
          if User.current.allowed_to?(:log_time, @project)
            @time_entry.save
          end
          if !journal.new_record?
            # Only send notification if something was actually changed
            flash[:notice] = l(:notice_successful_update)
          end
          call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
          respond_to do |format|
            format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
            format.xml  { head :ok }
          end
          return
        end
      end
      # failure
      respond_to do |format|
        format.html { }
        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
      end
    end
  rescue ActiveRecord::StaleObjectError
    # Optimistic locking exception
    flash.now[:error] = l(:notice_locking_conflict)
    # Remove the previously added attachments if issue was not updated
    attachments.each(&:destroy)
  end
end
# app/views/issues/_edit.rhtml
<% labelled_tabular_form_for :issue, @issue,
                             :url => {:action => 'edit', :id => @issue},
                              :html => {:id => 'issue-form',
                                        :class => nil,
                                        :multipart => true} do |f| %>

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  def edit
    @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
    @priorities = IssuePriority.all
    @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
    @time_entry = TimeEntry.new
    
    @notes = params[:notes]
    journal = @issue.init_journal(User.current, @notes)
    # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
    if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
      attrs = params[:issue].dup
      attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
      attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
      @issue.safe_attributes = attrs
    end

    if request.get?
      # nop
    else
      @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
      @time_entry.attributes = params[:time_entry]
      if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
        attachments = attach_files(@issue, params[:attachments])
        attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
        call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
        if @issue.save
          # Log spend time
          if User.current.allowed_to?(:log_time, @project)
            @time_entry.save
          end
          if !journal.new_record?
            # Only send notification if something was actually changed
            flash[:notice] = l(:notice_successful_update)
          end
          call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
          respond_to do |format|
            format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
            format.xml  { head :ok }
          end
          return
        end
      end
      # failure
      respond_to do |format|
        format.html { render :action => 'edit' }
        format.xml  { render :xml => @issue.errors, :status => :unprocessable_entity }
      end
    end
  rescue ActiveRecord::StaleObjectError
    # Optimistic locking exception
    flash.now[:error] = l(:notice_locking_conflict)
    # Remove the previously added attachments if issue was not updated
    attachments.each(&:destroy)
  end

  #--
  # Start converting to the Rails REST controllers
  #++
  def update
    edit
  end
end
# app/views/issues/_edit.rhtml
<% labelled_tabular_form_for :issue, @issue,
                             :url => {:action => update, :id => @issue},
                             :html => {:id => 'issue-form',
                                       :class => nil,
                                       :method => :put,
                                       :multipart => true} do |f| %>

Review

Instead of trying to dig into #edit and refactor everything at once, I put a little shim in place so #edit was mostly unmodified. Instead, I modified the issue forms so they are already using the new #update method. This will make it easier to move code over piece by piece. It will take a few refactorings but with the good test suite included in Redmine, it shouldn't be difficult.

Reference commit

Tagged: ruby redmine refactoring extract-method

I started to refactor the IssuesController today. Like I said yesterday, this class is full of bad smells and could really use some major rework. Picking a good place to start is overwhelming so I turned to a useful tool called flog. Flog is a tool that I use to find out how complex a class or method is. It checks for assignments (abc = 42), branches (if), and calls (@foo.bar) and gives each method a total score.

Using the rough numbers from flog, I can see which methods in IssuesController need the most work:

flog app/controllers/issues_controller.rb
  1423.2: flog total
    67.8: flog/method average

   194.9: IssuesController#bulk_edit
   145.6: IssuesController#edit
   136.4: IssuesController#retrieve_query
   131.6: IssuesController#move
   127.3: IssuesController#new
    98.3: IssuesController#index
    84.8: IssuesController#context_menu

The #bulk_edit method looks the worst according to flog so that's a good place to start.

The Refactoring

The easiest way to reduce the flog score for one method is to move a similar chunk of code to a new method. This won't affect the overall score since the complexity is just moved to the new method, but it can make it easier to refactor and reuse the code in other methods. For this refactoring, I used both extract method and move method to move a chunk of code from #bulk_edit to the Issue objects themselves.

Before

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController
  # Bulk edit a set of issues
  def bulk_edit
    if request.post?
      tracker = params[:tracker_id].blank? ? nil : @project.trackers.find_by_id(params[:tracker_id])
      status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
      priority = params[:priority_id].blank? ? nil : IssuePriority.find_by_id(params[:priority_id])
      assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
      category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
      fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.shared_versions.find_by_id(params[:fixed_version_id])
      custom_field_values = params[:custom_field_values] ? params[:custom_field_values].reject {|k,v| v.blank?} : nil
      
      unsaved_issue_ids = []      
      @issues.each do |issue|
        journal = issue.init_journal(User.current, params[:notes])
        issue.tracker = tracker if tracker
        issue.priority = priority if priority
        issue.assigned_to = assigned_to if assigned_to || params[:assigned_to_id] == 'none'
        issue.category = category if category || params[:category_id] == 'none'
        issue.fixed_version = fixed_version if fixed_version || params[:fixed_version_id] == 'none'
        issue.start_date = params[:start_date] unless params[:start_date].blank?
        issue.due_date = params[:due_date] unless params[:due_date].blank?
        issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
        issue.custom_field_values = custom_field_values if custom_field_values && !custom_field_values.empty?
        call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
        # Don't save any change to the issue if the user is not authorized to apply the requested status
        unless (status.nil? || (issue.new_statuses_allowed_to(User.current).include?(status) && issue.status = status)) && issue.save
          # Keep unsaved issue ids to display them in flash error
          unsaved_issue_ids << issue.id
        end
      end
      if unsaved_issue_ids.empty?
        flash[:notice] = l(:notice_successful_update) unless @issues.empty?
      else
        flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
                                                         :total => @issues.size,
                                                         :ids => '#' + unsaved_issue_ids.join(', #'))
      end
      redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
      return
    end
    @available_statuses = Workflow.available_statuses(@project)
    @custom_fields = @project.all_issue_custom_fields
  end
  #...
end
# app/models/issue.rb
class Issue < ActiveRecord::Base
  #...
end

After

# app/controllers/issues_controller.rb
class IssuesController < ApplicationController

  # Bulk edit a set of issues
  def bulk_edit
    if request.post?
      tracker = params[:tracker_id].blank? ? nil : @project.trackers.find_by_id(params[:tracker_id])
      status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
      priority = params[:priority_id].blank? ? nil : IssuePriority.find_by_id(params[:priority_id])
      assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
      category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
      fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.shared_versions.find_by_id(params[:fixed_version_id])
      custom_field_values = params[:custom_field_values] ? params[:custom_field_values].reject {|k,v| v.blank?} : nil

      # Need to merge in the records found above for Issue#bulk_edit.
      # Assuming this is done so the associations are only looked up once.
      merged_params = params.merge({
                                     :tracker => tracker,
                                     :status => status,
                                     :priority => priority,
                                     :assigned_to => assigned_to,
                                     :category => category,
                                     :fixed_version => fixed_version,
                                     :custom_field_values => custom_field_values
                                   })
      
      unsaved_issue_ids = []      
      @issues.each do |issue|
        unless issue.bulk_edit(merged_params)
          # Keep unsaved issue ids to display them in flash error
          unsaved_issue_ids << issue.id
        end
      end
      if unsaved_issue_ids.empty?
        flash[:notice] = l(:notice_successful_update) unless @issues.empty?
      else
        flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
                                                         :total => @issues.size,
                                                         :ids => '#' + unsaved_issue_ids.join(', #'))
      end
      redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
      return
    end
    @available_statuses = Workflow.available_statuses(@project)
    @custom_fields = @project.all_issue_custom_fields
  end
  # ...
end
# app/models/issue.rb
class Issue < ActiveRecord::Base
  def bulk_edit(params)
    journal = init_journal(User.current, params[:notes])
    self.tracker = params[:tracker] if params[:tracker]
    self.priority = params[:priority] if params[:priority]
    self.assigned_to = params[:assigned_to] if params[:assigned_to] || params[:assigned_to_id] == 'none'
    self.category = params[:category] if params[:category] || params[:category_id] == 'none'
    self.fixed_version = params[:fixed_version] if params[:fixed_version] || params[:fixed_version_id] == 'none'
    self.start_date = params[:start_date] unless params[:start_date].blank?
    self.due_date = params[:due_date] unless params[:due_date].blank?
    self.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
    self.custom_field_values = params[:custom_field_values] if params[:custom_field_values] && !params[:custom_field_values].empty?
    # TODO: Edit hook name
    Redmine::Hook.call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => self })

    # Don't save any change to the issue if the user is not authorized to apply the requested status
    return (params[:status].nil? || (new_statuses_allowed_to(User.current).include?(params[:status]) && self.status = params[:status])) && save
  end
  
  #...
end

Review

I had to put a small shim into #bulk_edit in order for the extraction to work without changing how the code works. This is shown by the merged_params variable. Like I've said previously, an inline comment is an easy way to spot a new refactoring. This time, I added a comment to myself to remind me later.

Now that the #bulk_edit method has been moved to the Issue class, the flog score for IssuesController is looking a lot better now.

flog app/controllers/issues_controller.rb 
  1354.9: flog total
    64.5: flog/method average

   145.6: IssuesController#edit
   136.4: IssuesController#retrieve_query
   131.6: IssuesController#move
   127.3: IssuesController#new
   126.6: IssuesController#bulk_edit
    98.3: IssuesController#index
    84.8: IssuesController#context_menu

If I run flog on the Issue model, you can still see the 60 points. This is because the complexity was just moved around, not actually removed.

flog app/models/issue.rb 
   788.5: flog total
    15.8: flog/method average

    ...
    61.1: Issue#bulk_edit
    ...

Now there are a few things I can refactor in the IssuesController and Issue classes. I think I'll stick with the controller for now, since it's in worse condition.

Reference commit

Tagged: ruby redmine refactoring move-method extract-method

I tried to start this week's refactorings with some major changes to one of the worst areas of Redmine, the IssuesController. Weighing in at over 576 lines and 20 actions, it is full of really bad smells and hasn't getting any better over time. But after spending two hours on it, I was getting nowhere and decided to start over. So instead of going for the big impact changes, I'm going to tackle the little changes one by one.

The Refactoring

Today's refactoring involves moving some of the IssuesController's routing tests from the functional tests into the integration tests.

Before

# test/functional/issues_controller_test.rb
class IssuesControllerTest < ActionController::TestCase
  def test_index_routing
    assert_routing(
      {:method => :get, :path => '/issues'},
      :controller => 'issues', :action => 'index'
    )
  end

  # ...

  def test_index_with_project_routing
    assert_routing(
      {:method => :get, :path => '/projects/23/issues'},
      :controller => 'issues', :action => 'index', :project_id => '23'
    )
  end
  
  # ...

  def test_index_with_project_routing
    assert_routing(
      {:method => :get, :path => 'projects/23/issues'},
      :controller => 'issues', :action => 'index', :project_id => '23'
    )
  end

  # ...

  def test_index_with_project_routing_formatted
    assert_routing(
      {:method => :get, :path => 'projects/23/issues.pdf'},
      :controller => 'issues', :action => 'index', :project_id => '23', :format => 'pdf'
    )
    assert_routing(
      {:method => :get, :path => 'projects/23/issues.atom'},
      :controller => 'issues', :action => 'index', :project_id => '23', :format => 'atom'
    )
  end
  
  # ...

  def test_index_formatted
    assert_routing(
      {:method => :get, :path => 'issues.pdf'},
      :controller => 'issues', :action => 'index', :format => 'pdf'
    )
    assert_routing(
      {:method => :get, :path => 'issues.atom'},
      :controller => 'issues', :action => 'index', :format => 'atom'
    )
  end
  
  # ...
  
  def test_show_routing
    assert_routing(
      {:method => :get, :path => '/issues/64'},
      :controller => 'issues', :action => 'show', :id => '64'
    )
  end
  
  def test_show_routing_formatted
    assert_routing(
      {:method => :get, :path => '/issues/2332.pdf'},
      :controller => 'issues', :action => 'show', :id => '2332', :format => 'pdf'
    )
    assert_routing(
      {:method => :get, :path => '/issues/23123.atom'},
      :controller => 'issues', :action => 'show', :id => '23123', :format => 'atom'
    )
  end

  # ...

  def test_new_routing
    assert_routing(
      {:method => :get, :path => '/projects/1/issues/new'},
      :controller => 'issues', :action => 'new', :project_id => '1'
    )
    assert_recognizes(
      {:controller => 'issues', :action => 'new', :project_id => '1'},
      {:method => :post, :path => '/projects/1/issues'}
    )
  end

  # ...

  def test_copy_routing
    assert_routing(
      {:method => :get, :path => '/projects/world_domination/issues/567/copy'},
      :controller => 'issues', :action => 'new', :project_id => 'world_domination', :copy_from => '567'
    )
  end
  
  # ...
  
  def test_edit_routing
    assert_routing(
      {:method => :get, :path => '/issues/1/edit'},
      :controller => 'issues', :action => 'edit', :id => '1'
    )
    assert_recognizes( #TODO: use a PUT on the issue URI isntead, need to adjust form
      {:controller => 'issues', :action => 'edit', :id => '1'},
      {:method => :post, :path => '/issues/1/edit'}
    )
  end
  
  # ...
  
  def test_reply_routing
    assert_routing(
      {:method => :post, :path => '/issues/1/quoted'},
      :controller => 'issues', :action => 'reply', :id => '1'
    )
  end
  
  # ...

  def test_move_routing
    assert_routing(
      {:method => :get, :path => '/issues/1/move'},
      :controller => 'issues', :action => 'move', :id => '1'
    )
    assert_recognizes(
      {:controller => 'issues', :action => 'move', :id => '1'},
      {:method => :post, :path => '/issues/1/move'}
    )
  end
  
  # ...

  def test_destroy_routing
    assert_recognizes( #TODO: use DELETE on issue URI (need to change forms)
      {:controller => 'issues', :action => 'destroy', :id => '1'},
      {:method => :post, :path => '/issues/1/destroy'}
    )
  end

end

# test/integration/routing_test.rb
class RoutingTest < ActionController::IntegrationTest
end

After

# test/functional/issues_controller_test.rb
class IssuesControllerTest < ActionController::TestCase
  # ...
end

# test/integration/routing_test.rb
class RoutingTest < ActionController::IntegrationTest
  context "issues" do
    # REST actions
    should_route :get, "/issues", :controller => 'issues', :action => 'index'
    should_route :get, "/issues.pdf", :controller => 'issues', :action => 'index', :format => 'pdf'
    should_route :get, "/issues.atom", :controller => 'issues', :action => 'index', :format => 'atom'
    should_route :get, "/issues.xml", :controller => 'issues', :action => 'index', :format => 'xml'
    should_route :get, "/projects/23/issues", :controller => 'issues', :action => 'index', :project_id => '23'
    should_route :get, "/projects/23/issues.pdf", :controller => 'issues', :action => 'index', :project_id => '23', :format => 'pdf'
    should_route :get, "/projects/23/issues.atom", :controller => 'issues', :action => 'index', :project_id => '23', :format => 'atom'
    should_route :get, "/projects/23/issues.xml", :controller => 'issues', :action => 'index', :project_id => '23', :format => 'xml'
    should_route :get, "/issues/64", :controller => 'issues', :action => 'show', :id => '64'
    should_route :get, "/issues/64.pdf", :controller => 'issues', :action => 'show', :id => '64', :format => 'pdf'
    should_route :get, "/issues/64.atom", :controller => 'issues', :action => 'show', :id => '64', :format => 'atom'
    should_route :get, "/issues/64.xml", :controller => 'issues', :action => 'show', :id => '64', :format => 'xml'

    should_route :get, "/projects/23/issues/new", :controller => 'issues', :action => 'new', :project_id => '23'
      
    should_route :get, "/issues/64/edit", :controller => 'issues', :action => 'edit', :id => '64'
    # TODO: Should use PUT
    should_route :post, "/issues/64/edit", :controller => 'issues', :action => 'edit', :id => '64'

    # TODO: Should use DELETE
    should_route :post, "/issues/64/destroy", :controller => 'issues', :action => 'destroy', :id => '64'
    
    # Extra actions
    should_route :get, "/projects/23/issues/64/copy", :controller => 'issues', :action => 'new', :project_id => '23', :copy_from => '64'

    should_route :get, "/issues/1/move", :controller => 'issues', :action => 'move', :id => '1'
    should_route :post, "/issues/1/move", :controller => 'issues', :action => 'move', :id => '1'
    
    should_route :post, "/issues/1/quoted", :controller => 'issues', :action => 'reply', :id => '1'

    should_route :get, "/issues/calendar", :controller => 'issues', :action => 'calendar'
    should_route :post, "/issues/calendar", :controller => 'issues', :action => 'calendar'
    should_route :get, "/projects/project-name/issues/calendar", :controller => 'issues', :action => 'calendar', :project_id => 'project-name'
    should_route :post, "/projects/project-name/issues/calendar", :controller => 'issues', :action => 'calendar', :project_id => 'project-name'

    should_route :get, "/issues/gantt", :controller => 'issues', :action => 'gantt'
    should_route :post, "/issues/gantt", :controller => 'issues', :action => 'gantt'
    should_route :get, "/projects/project-name/issues/gantt", :controller => 'issues', :action => 'gantt', :project_id => 'project-name'
    should_route :post, "/projects/project-name/issues/gantt", :controller => 'issues', :action => 'gantt', :project_id => 'project-name'
  end
  
end

Review

It's not really apparent here, but if you look at the diff for this refactoring you can see that all of the routing tests were interspaced in the functional test. This made it extremely difficult to find how the routes should behave for the IssuesController, short of reading the routes.rb. Now with the integration test using shoulda's should_route it's really easy to see how the HTTP verbs and urls map to the controllers and actions.

Reference commit

Tagged: ruby redmine refactoring testing move-method

Since I'm working so much with Redmine and Open Source, I wanted to take a few minutes and post an update about some of the major things I've been working on recently.

Redmine LDAP integration

I've been working with a client on several new features for Redmine's LDAP integration. He's wanting to add a lot more automation for the user management in Redmine so we've been doing a lot of integration with LDAP over the past two weeks.

We're planning to submit these new features back to Redmine in a couple of weeks but I've already started on a new plugin that has some of the automation:

  1. Rake Task: Add all of the users found in LDAP to Redmine, optionally adding them to a Redmine group.
  2. Rake Task: Lock (or unlock) any account registered in Redmine with an LDAP connection but not found. (e.g. if a user was removed from LDAP, this will lock their account in Redmine)
  3. A web user interface to "Sync LDAP" which will run both of the above commands
  4. Rake Task: Add the existing Redmine users to a default group (e.g. Everyone should be in the Reporter group)

Some of the Redmine core features we want to submit are:

  1. Automatically adding new users to specific Redmine groups based on their LDAP connection.
  2. Extending the incoming mail handler to check for accounts in LDAP and create them on the fly.
  3. Adding a custom filter for each LDAP connection to refine the user search (e.g. getting all the members of the "Developers" group out of LDAP: (&(objectcategory=group)(member="Developers"))

Redmine Bulk Time Entry plugin

I'm just about done with a new release for the Redmine Bulk Time Entry plugin. I've been frantically trying to get all 70+ of my Redmine plugins updated for the 0.9 Redmine release last month. I'm going to be asking for some help to maintain them once I get my first big project of 2010 launched...

See Project Run

I've been working hard to get the first of my 12 businesses in 2010 launched, SeeProjectRun. I'm not quite ready to open it up for sign ups yet but I've added a form where you can register for a private beta invite. If you're a freelancer or small technology business, I'll appreciate it if you could sign up for the beta.

That's what I've been working on lately. It's stressful and overwhelming at times but a lot of good things are coming out from it.

Eric Davis

Tagged: redmine ldap business seeprojectrun

Right after publishing the final refactoring for AuthSourceLdap, another really good one jumped out at me and I couldn't wait until next week to fix it.

The Refactoring

Before

# app/models/auth_source_ldap.rb
class AuthSourceLdap < AuthSource 
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = []
    # get user's DN
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    dn = String.new
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     :attributes=> search_attributes) do |entry|
      dn = entry.dn
      attrs = get_user_attributes_from_ldap_entry(entry) if onthefly_register?
      logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug?

    end

    if authenticate_dn(dn, password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end
end

After

# app/models/auth_source_ldap.rb
class AuthSourceLdap < AuthSource 
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = get_user_dn(login)
    
    if attrs.first && attrs.first[:dn] && authenticate_dn(attrs.first[:dn], password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end

  # Get the user's dn and any attributes for them, given their login
  def get_user_dn(login)
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    attrs = []
    
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     :attributes=> search_attributes) do |entry|

      if onthefly_register?
        attrs = get_user_attributes_from_ldap_entry(entry)
      else
        attrs = [:dn => entry.dn]
      end

      logger.debug "DN found for #{login}: #{attrs.first[:dn]}" if logger && logger.debug?
    end

    attrs
  end
end

Review

This refactor moves a massive block of code out of AuthSourceLdap#authenticate. Now #authenticate works at a higher level of abstraction and relies on #get_user_dn and #authenticate_dn to handle the actual LDAP queries.

The only reason I noticed this reafactoring was with the single inline comment remaining in #authenticate. Whenever I see an inline comment, I try to stop and see if that code can be isolated and moved to another method.

Reference commit

Tagged: ruby redmine refactoring extract-method

The Refactoring

I mentioned yesterday that I wanted to refactor the ternary operation in the ldap_con#search call so today I used extract method to pull it out.

Before

# app/models/auth_source_ldap.rb
class AuthSourceLdap < AuthSource 
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = []
    # get user's DN
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    dn = String.new
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     # only ask for the DN if on-the-fly registration is disabled
                     :attributes=> (onthefly_register? ? ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail] : ['dn'])) do |entry|
      dn = entry.dn
      attrs = get_user_attributes_from_ldap_entry(entry) if onthefly_register?
      logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug?

    end

    if authenticate_dn(dn, password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end
end

After

# app/models/auth_source_ldap.rb
class AuthSourceLdap < AuthSource 
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = []
    # get user's DN
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    dn = String.new
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     :attributes=> search_attributes) do |entry|
      dn = entry.dn
      attrs = get_user_attributes_from_ldap_entry(entry) if onthefly_register?
      logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug?

    end

    if authenticate_dn(dn, password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end

  # Return the attributes needed for the LDAP search.  It will only
  # include the user attributes if on-the-fly registration is enabled
  def search_attributes
    if onthefly_register?
      ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail]
    else
      ['dn']
    end
  end
end

Review

With this refactor, I'm ready to conclude working on AuthSourceLdap for now. With it cleaned up and well factored, it is now easier to implement new features that I have in progress. Like in cooking, it's easier to create good things when the kitchen is clean.

Reference commit

Tagged: ruby redmine refactoring extract-method

With the help of Nate Sutton I've done two small refactorings to AuthSourceLdap#authenticate and #authenticate_dn.

The Refactoring - One

This refactoring moved the check for an empty DN into the #authenticate_dn method. It makes sense to have all of the business logic and data validations for authenticating in one method.

Before

# app/models/auth_source_ldap.rb
class AuthSourceLdap < AuthSource 
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = []
    # get user's DN
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    dn = String.new
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     # only ask for the DN if on-the-fly registration is disabled
                     :attributes=> (onthefly_register? ? ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail] : ['dn'])) do |entry|
      dn = entry.dn
      attrs = get_user_attributes_from_ldap_entry(entry) if onthefly_register?

    end
    return nil if dn.empty?
    logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug?

    if authenticate_dn(dn, password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    else
      return nil
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end

  # Check if a DN (user record) authenticates with the password
  def authenticate_dn(dn, password)
    ldap_con = initialize_ldap_con(dn, password)
    return ldap_con.bind
  end
end

After

# app/models/auth_source_ldap.rb
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = []
    # get user's DN
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    dn = String.new
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     # only ask for the DN if on-the-fly registration is disabled
                     :attributes=> (onthefly_register? ? ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail] : ['dn'])) do |entry|
      dn = entry.dn
      attrs = get_user_attributes_from_ldap_entry(entry) if onthefly_register?
      logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug?

    end

    if authenticate_dn(dn, password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    else
      return nil
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end

  # Check if a DN (user record) authenticates with the password
  def authenticate_dn(dn, password)
    return nil if dn.empty?

    ldap_con = initialize_ldap_con(dn, password)
    return ldap_con.bind
  end
end

The Refactoring - Two

This refactoring cleans up the internals of authenticate_dn by using Ruby's ability to implicitly return the last value in a method.

Before

# app/models/auth_source_ldap.rb
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = []
    # get user's DN
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    dn = String.new
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     # only ask for the DN if on-the-fly registration is disabled
                     :attributes=> (onthefly_register? ? ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail] : ['dn'])) do |entry|
      dn = entry.dn
      attrs = get_user_attributes_from_ldap_entry(entry) if onthefly_register?
      logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug?

    end

    if authenticate_dn(dn, password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    else
      return nil
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end

  # Check if a DN (user record) authenticates with the password
  def authenticate_dn(dn, password)
    return nil if dn.empty?

    ldap_con = initialize_ldap_con(dn, password)
    return ldap_con.bind
  end
end

After

# app/models/auth_source_ldap.rb
  def authenticate(login, password)
    return nil if login.blank? || password.blank?
    attrs = []
    # get user's DN
    ldap_con = initialize_ldap_con(self.account, self.account_password)
    login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) 
    object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) 
    dn = String.new
    ldap_con.search( :base => self.base_dn, 
                     :filter => object_filter & login_filter, 
                     # only ask for the DN if on-the-fly registration is disabled
                     :attributes=> (onthefly_register? ? ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail] : ['dn'])) do |entry|
      dn = entry.dn
      attrs = get_user_attributes_from_ldap_entry(entry) if onthefly_register?
      logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug?

    end

    if authenticate_dn(dn, password)
      logger.debug "Authentication successful for '#{login}'" if logger && logger.debug?
      return attrs
    end
  rescue  Net::LDAP::LdapError => text
    raise "LdapError: " + text
  end

  # Check if a DN (user record) authenticates with the password
  def authenticate_dn(dn, password)
    if dn.present? && password.present?
      initialize_ldap_con(dn, password).bind
    end
  end
end

Review

With these two changes, what #authenticate is doing has become a lot clearer for each the different return paths (only 3 now). I think there is only one more refactoring I want to make to it before I move on; the ternary operation in the #search call. Once that's refactored, I'll be able to easily port over a new feature from one of my Redmine clients.

Reference commits

Tagged: ruby redmine refactoring