Skip to content

Commit

Permalink
[59539] switch scheduling mode when modifying follows relations
Browse files Browse the repository at this point in the history
When a work package becomes a successor of another work package, its
scheduling mode is switched to automatic if it has no children so that
it can be scheduled as soon as possible automatically.

Similarly, when a work package is no longer a successor of any other
work package, its scheduling mode is switched to manual if it has no
children and no dates so that it can keep its current dates.
  • Loading branch information
cbliard committed Dec 17, 2024
1 parent 7923850 commit b8c7bb2
Show file tree
Hide file tree
Showing 15 changed files with 672 additions and 61 deletions.
6 changes: 6 additions & 0 deletions app/models/relation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ class Relation < ApplicationRecord
scope :follows_with_lag,
-> { follows.where("lag > 0") }

scope :of_successor,
->(work_package) { where(from: work_package) }

scope :not_of_predecessor,
->(work_package) { where.not(to: work_package) }

validates :lag, numericality: { allow_nil: true }

validates :to, uniqueness: { scope: :from }
Expand Down
31 changes: 25 additions & 6 deletions app/models/work_packages/scopes/for_scheduling.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
module WorkPackages::Scopes
module ForScheduling
extend ActiveSupport::Concern
using CoreExtensions::SquishSql

class_methods do
# Fetches all work packages that need to be evaluated for eventual
Expand Down Expand Up @@ -108,13 +109,13 @@ module ForScheduling
# @param work_packages WorkPackage[] A set of work packages for which the
# set of related work packages that might be subject to reschedule is
# fetched.
def for_scheduling(work_packages)
def for_scheduling(work_packages, switching_to_automatic_mode: [])
return none if work_packages.empty?

sql = <<~SQL.squish
WITH
RECURSIVE
#{scheduling_paths_sql(work_packages)}
#{scheduling_paths_sql(work_packages, switching_to_automatic_mode:)}
SELECT id
FROM to_schedule
Expand Down Expand Up @@ -159,14 +160,25 @@ def for_scheduling(work_packages)
#
# Paths whose ending work package is marked to be manually scheduled are
# not joined with any more.
def scheduling_paths_sql(work_packages)
def scheduling_paths_sql(work_packages, switching_to_automatic_mode: [])
automatic_ids = switching_to_automatic_mode.map do |wp|
::OpenProject::SqlSanitization.sanitize("(:id)", id: wp.id)
end.join(", ")

values = work_packages.map do |wp|
::OpenProject::SqlSanitization
.sanitize "(:id, false, false, true)",
id: wp.id
end.join(", ")

<<~SQL.squish
-- All work packages that are switching to automatic scheduling mode
-- but are still seen as manually scheduled from the database's perspective.
switching_to_automatic_mode (id) AS (
SELECT id::bigint FROM (VALUES #{automatic_ids.presence || '(NULL)'}) AS t(id)
),
-- recursively fetch all work packages that are eligible for rescheduling
to_schedule (id, manually, hierarchy_up, origin) AS (
SELECT * FROM (VALUES#{values}) AS t(id, manually, hierarchy_up, origin)
Expand All @@ -175,9 +187,14 @@ def scheduling_paths_sql(work_packages)
SELECT
relations.from_id id,
(related_work_packages.schedule_manually
OR (COALESCE(descendants.manually, false)
AND NOT (to_schedule.origin AND relations.hierarchy_up))
(
(
related_work_packages.schedule_manually
AND switching_to_automatic_mode.id IS NULL
) OR (
COALESCE(descendants.manually, false)
AND NOT (to_schedule.origin AND relations.hierarchy_up)
)
) manually,
relations.hierarchy_up,
false origin
Expand Down Expand Up @@ -211,6 +228,8 @@ def scheduling_paths_sql(work_packages)
) relations ON relations.to_id = to_schedule.id
LEFT JOIN work_packages related_work_packages
ON relations.from_id = related_work_packages.id
LEFT JOIN switching_to_automatic_mode
ON related_work_packages.id = switching_to_automatic_mode.id
LEFT JOIN LATERAL (
SELECT
descendant_hierarchies.ancestor_id from_id,
Expand Down
41 changes: 36 additions & 5 deletions app/services/relations/base_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Relations::BaseService < BaseServices::BaseCallable
attr_accessor :user

def initialize(user:)
super()
self.user = user
end

Expand All @@ -54,20 +55,22 @@ def update_relation(model, attributes)
end

def set_defaults(model)
if Relation::TYPE_FOLLOWS == model.relation_type
if model.follows?
model.lag ||= 0
else
model.lag = nil
end
end

def reschedule(model)
def reschedule(relation)
schedule_result = WorkPackages::SetScheduleService
.new(user:, work_package: model.to)
.new(user:,
work_package: relation.predecessor,
switching_to_automatic_mode: switching_to_automatic_mode(relation))
.call

# The to-work_package will not be altered by the schedule service so
# we do not have to save the result of the service.
# The predecessor work package will not be altered by the schedule service so
# we do not have to save the result of the service, only the dependent results.
save_result = if schedule_result.success?
schedule_result.dependent_results.all? { |dr| !dr.result.changed? || dr.result.save(validate: false) }
end || false
Expand All @@ -76,4 +79,32 @@ def reschedule(model)

schedule_result
end

def switching_to_automatic_mode(relation)
if should_switch_successor_to_automatic_mode?(relation)
[relation.successor]
else
[]
end
end

def should_switch_successor_to_automatic_mode?(relation)
relation.follows? \
&& creating? \
&& last_successor_relation?(relation) \
&& has_no_children?(relation.successor)
end

def creating?
self.class.name.include?("Create")
end

def last_successor_relation?(relation)
Relation.follows.of_successor(relation.successor)
.not_of_predecessor(relation.predecessor).none?
end

def has_no_children?(work_package)
!WorkPackage.exists?(parent: work_package)
end
end
30 changes: 29 additions & 1 deletion app/services/relations/delete_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,32 @@
# See COPYRIGHT and LICENSE files for more details.
#++

class Relations::DeleteService < BaseServices::Delete; end
class Relations::DeleteService < BaseServices::Delete
def after_perform(_result)
result = super
if result.success? && successor_must_switch_to_manual_mode?
deleted_relation.successor.update(schedule_manually: true)
end
result
end

private

def deleted_relation
model
end

def successor_must_switch_to_manual_mode?
deleted_relation.follows? \
&& successor_has_dates? \
&& was_last_relation_to_the_successor?
end

def successor_has_dates?
deleted_relation.successor.start_date.present? || deleted_relation.successor.due_date.present?
end

def was_last_relation_to_the_successor?
Relation.follows.of_successor(deleted_relation.successor).none?
end
end
18 changes: 15 additions & 3 deletions app/services/work_packages/schedule_dependency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@
# package, but are necessary to accurately determine the new start and due
# dates of the moving work packages.
class WorkPackages::ScheduleDependency
attr_accessor :dependencies
attr_accessor :dependencies, :switching_to_automatic_mode

def initialize(moved_work_packages)
def initialize(moved_work_packages, switching_to_automatic_mode: [])
self.moved_work_packages = Array(moved_work_packages)
self.switching_to_automatic_mode = Array(switching_to_automatic_mode)

preload_scheduling_data

Expand Down Expand Up @@ -136,7 +137,7 @@ def create_dependencies

def moving_work_packages
@moving_work_packages ||= WorkPackage
.for_scheduling(moved_work_packages)
.for_scheduling(moved_work_packages, switching_to_automatic_mode:)
end

# All work packages preloaded during initialization.
Expand Down Expand Up @@ -166,6 +167,8 @@ def preload_scheduling_data

# rehydrate the predecessors and followers of follows relations
rehydrate_follows_relations

fix_switching_to_automatic_mode_work_packages
end

# Returns all the descendants of moved and moving work packages that are not
Expand Down Expand Up @@ -207,4 +210,13 @@ def rehydrate_follows_relations
relation.to = work_package_by_id(relation.to_id)
end
end

def fix_switching_to_automatic_mode_work_packages
ids = switching_to_automatic_mode.map(&:id)
known_work_packages.each do |work_package|
if ids.include?(work_package.id)
work_package.schedule_manually = false
end
end
end
end
7 changes: 4 additions & 3 deletions app/services/work_packages/set_schedule_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@
#++

class WorkPackages::SetScheduleService
attr_accessor :user, :work_packages, :initiated_by
attr_accessor :user, :work_packages, :initiated_by, :switching_to_automatic_mode

def initialize(user:, work_package:, initiated_by: nil)
def initialize(user:, work_package:, initiated_by: nil, switching_to_automatic_mode: [])
self.user = user
self.work_packages = Array(work_package)
self.initiated_by = initiated_by
self.switching_to_automatic_mode = switching_to_automatic_mode
end

def call(changed_attributes = %i(start_date due_date))
Expand Down Expand Up @@ -95,7 +96,7 @@ def schedule_by_parent
def schedule_following
altered = []

WorkPackages::ScheduleDependency.new(work_packages).in_schedule_order do |scheduled, dependency|
WorkPackages::ScheduleDependency.new(work_packages, switching_to_automatic_mode:).in_schedule_order do |scheduled, dependency|
reschedule(scheduled, dependency)

altered << scheduled if scheduled.changed?
Expand Down
Loading

0 comments on commit b8c7bb2

Please sign in to comment.