HEX
Server: Apache/2.4.41 (Ubuntu)
System: Linux vmi1674223.contaboserver.net 5.4.0-182-generic #202-Ubuntu SMP Fri Apr 26 12:29:36 UTC 2024 x86_64
User: root (0)
PHP: 7.4.3-4ubuntu2.22
Disabled: pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,
Upload Files
File: //opt/openproject/db/migrate/20190722082648_add_derived_estimated_hours_to_work_packages.rb
class AddDerivedEstimatedHoursToWorkPackages < ActiveRecord::Migration[5.2]
  class WorkPackageWithRelations < ActiveRecord::Base
    self.table_name = "work_packages"

    scope :with_children, ->(*args) do
      rel = "relations"
      wp = "work_packages"

      query = "EXISTS (SELECT 1 FROM #{rel} WHERE #{rel}.from_id = #{wp}.id AND #{rel}.hierarchy > 0 LIMIT 1)"

      where(query)
    end
  end

  def change
    add_column :work_packages, :derived_estimated_hours, :float
    add_column :work_package_journals, :derived_estimated_hours, :float

    reversible do |change|
      change.up do
        WorkPackage.transaction do
          migrate_to_derived_estimated_hours!
        end
      end

      change.down do
        WorkPackage.transaction do
          rollback_from_derived_estimated_hours!
        end
      end
    end
  end

  # Before this migration all work packages who have children had their
  # estimated hours set based on their children through the UpdateAncestorsService.
  #
  # We move this value to the derived_estimated_hours column and clear
  # the estimated_hours column. In the future users can estimate the time
  # for parent work packages separately while the UpdateAncestorsService
  # only touches the derived_estimated_hours column.
  def migrate_to_derived_estimated_hours!
    last_id = Journal.order(id: :desc).limit(1).pluck(:id).first || 0

    work_packages = WorkPackageWithRelations.with_children.where("estimated_hours > ?", 0)
    work_packages.update_all("derived_estimated_hours = estimated_hours, estimated_hours = NULL")
    work_packages = WorkPackageWithRelations.with_children.where("derived_estimated_hours > ?", 0)

    create_journals_for work_packages
    create_work_package_journals last_id: last_id
    create_customizable_journals last_id: last_id
    create_attachable_journals last_id: last_id

    touch_work_packages work_packages # to invalidate cache
  end

  def rollback_from_derived_estimated_hours!
    last_id = Journal.order(id: :desc).limit(1).pluck(:id).first || 0

    work_packages = WorkPackageWithRelations.with_children.where("derived_estimated_hours > ?", 0)
    work_packages.update_all("estimated_hours = derived_estimated_hours, derived_estimated_hours = NULL")
    work_packages = WorkPackageWithRelations.with_children.where("estimated_hours > ?", 0)

    create_journals_for work_packages, notes: rollback_notes
    create_work_package_journals last_id: last_id
    create_customizable_journals last_id: last_id
    create_attachable_journals last_id: last_id

    touch_work_packages work_packages # to invalidate cache
  end

  ##
  # Creates a new journal for each work package with the next version.
  # The respective work_package journal is created in a separate step.
  def create_journals_for(work_packages, author: journal_author, notes: journal_notes)
    WorkPackage.connection.execute("
      INSERT INTO #{Journal.table_name} (journable_type, journable_id, user_id, notes, created_at, version, activity_type)
      SELECT
        'WorkPackage',
        parents.id,
        #{author.id},
        #{WorkPackage.connection.quote(notes)},
        NOW(),
        (SELECT MAX(version) FROM journals WHERE journable_id = parents.id AND journable_type = 'WorkPackage') + 1,
        'work_packages'
      FROM (
        #{work_packages.select(:id).to_sql}
      ) AS parents
    ")
  end

  def journal_author
    @journal_author ||= User.system
  end

  def journal_notes
    "_'Estimated hours' changed to 'Derived estimated hours'_"
  end

  def rollback_notes
    "_'Derived estimated hours' rolled back to 'Estimated hours'_"
  end

  ##
  # Creates work package journals for the move of estimated_hours to derived_estimated_hours.
  #
  # For each newly created journal (see above) it inserts the respective work package's
  # current estimated_hours (deleted) and derived estimated hours (previously estimated hours).
  # All other attributes of the work package journal entry are copied from the previous
  # work package journal entry (i.e. the values are not changed).
  #
  # @param last_id [Integer] The ID of the last journal before the journals for the migration were created.
  def create_work_package_journals(last_id:)
    journals = "journals"
    wp_journals = "work_package_journals"
    work_packages = "work_packages"

    WorkPackage.connection.execute("
      INSERT INTO #{wp_journals} (
        journal_id, type_id, project_id, subject, description, due_date, category_id, status_id,
        assigned_to_id, priority_id, fixed_version_id, author_id, done_ratio,
        start_date, parent_id, responsible_id, cost_object_id, story_points, remaining_hours,
        estimated_hours, derived_estimated_hours
      )
      SELECT *
      FROM (
        SELECT
          #{journals}.id, #{wp_journals}.type_id, #{wp_journals}.project_id, #{wp_journals}.subject,
          #{wp_journals}.description, #{wp_journals}.due_date, #{wp_journals}.category_id, #{wp_journals}.status_id,
          #{wp_journals}.assigned_to_id, #{wp_journals}.priority_id, #{wp_journals}.fixed_version_id, #{wp_journals}.author_id,
          #{wp_journals}.done_ratio, #{wp_journals}.start_date, #{wp_journals}.parent_id, #{wp_journals}.responsible_id,
          #{wp_journals}.cost_object_id, #{wp_journals}.story_points, #{wp_journals}.remaining_hours,
          #{work_packages}.estimated_hours, #{work_packages}.derived_estimated_hours
        FROM #{journals} -- take the journal ID from here (ID of newly created journals from above)
          LEFT JOIN #{work_packages} -- take the current (derived) estimated hours from here
          ON #{work_packages}.id = #{journals}.journable_id AND #{journals}.journable_type = 'WorkPackage'
          LEFT JOIN #{wp_journals} -- keep everything else the same
          ON #{wp_journals}.journal_id = (
            SELECT MAX(id)
            FROM #{journals}
            WHERE journable_id = #{work_packages}.id AND journable_type = 'WorkPackage' AND #{journals}.id <= #{last_id}
            -- we are selecting the latest previous (hence <= last_id) work package journal here to copy its values
          )
        WHERE #{journals}.id > #{last_id} -- make sure to only create entries for the newly created journals
      ) AS results
    ")
  end

  def create_customizable_journals(last_id:)
    journals = "journals"
    customizable = "customizable_journals"
    work_packages = "work_packages"

    WorkPackage.connection.execute("
      INSERT INTO #{customizable} (journal_id, custom_field_id, value)
      SELECT #{journals}.id, #{customizable}.custom_field_id, #{customizable}.value
      FROM #{journals} -- take the journal ID from here (ID of newly created journals from above)
        LEFT JOIN #{work_packages}
        ON #{work_packages}.id = #{journals}.journable_id AND #{journals}.journable_type = 'WorkPackage'
        RIGHT JOIN #{customizable} -- keep everything else the same; there can be multiple customizable journals (custom fields)
        ON #{customizable}.journal_id = (
          SELECT MAX(id)
          FROM #{journals}
          WHERE #{journals}.journable_id = #{work_packages}.id AND journable_type = 'WorkPackage' AND #{journals}.id <= #{last_id}
          -- we are selecting the latest previous (hence <= last_id) customizable journal here to copy its values
        )
      WHERE #{journals}.id > #{last_id} -- make sure to only create entries for the newly created journals
    ")
  end

  def create_attachable_journals(last_id:)
    journals = "journals"
    attachable = "attachable_journals"
    work_packages = "work_packages"

    WorkPackage.connection.execute("
      INSERT INTO #{attachable} (journal_id, attachment_id, filename)
      SELECT #{journals}.id, #{attachable}.attachment_id, #{attachable}.filename
      FROM #{journals} -- take the journal ID from here (ID of newly created journals from above)
        LEFT JOIN #{work_packages}
        ON #{work_packages}.id = #{journals}.journable_id AND #{journals}.journable_type = 'WorkPackage'
        RIGHT JOIN #{attachable} -- keep everything else the same; there can be multiple attachable journals (attachments)
        ON #{attachable}.journal_id = (
          SELECT MAX(id)
          FROM #{journals}
          WHERE #{journals}.journable_id = #{work_packages}.id AND journable_type = 'WorkPackage' AND #{journals}.id <= #{last_id}
          -- we are selecting the latest previous (hence <= last_id) customizable journal here to copy its values
        )
      WHERE #{journals}.id > #{last_id} -- make sure to only create entries for the newly created journals
    ")
  end

  def touch_work_packages(work_packages)
    where = work_packages.arel.where_sql

    WorkPackage.connection.execute("
      UPDATE work_packages SET updated_at = NOW(), lock_version = lock_version + 1 #{where}
    ")
  end
end