Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[59539] Add automatic scheduling mode #17235

Draft
wants to merge 33 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
731bed7
[42388] Migrate scheduling mode and lags
cbliard Nov 20, 2024
7003b23
Define a 'scheduling mode' column in table_helpers
cbliard Nov 22, 2024
c155b40
[59539] Migration: followers and parents have automatic scheduling mode
cbliard Nov 22, 2024
ed3683f
[59539] Migration: set lag for follows relations
cbliard Nov 22, 2024
dcd5340
[59539] Respect non-working days when computing lags in migration
cbliard Nov 25, 2024
d63c9c1
[59539] Only set lag for the closest relation in the migration
cbliard Nov 25, 2024
45d6a3a
[59539] Keep manual scheduling mode in migration
cbliard Nov 27, 2024
ca5c6ac
[59539] Make work packages manually scheduled by default
cbliard Nov 28, 2024
b58a3ff
Show non-working days in the rendered table of table helpers
cbliard Dec 10, 2024
24e06e8
[59539] Start as soon as possible in automatic scheduling mode
cbliard Dec 10, 2024
47f852b
Fix broken feature test
cbliard Dec 11, 2024
703d077
Simplify method calls
cbliard Dec 11, 2024
704bc6e
table helpers: rename properties column to predecessors
cbliard Dec 12, 2024
5017043
[59539] Correctly reschedule predecessors needing relations rescheduling
cbliard Dec 13, 2024
3cc1221
refactor: reword some tests cases
cbliard Dec 13, 2024
853b2d4
[59539] Update seeding data to be compatible with new automatic sched…
cbliard Dec 16, 2024
e1a7332
[59539] switch scheduling mode when modifying follows relations
cbliard Dec 17, 2024
93703e7
Fix unit tests broken by previous commit
cbliard Dec 17, 2024
969cf23
refactor: rewrite with positive assertions
cbliard Dec 24, 2024
faee361
table_helpers: Fix issue with empty tables
cbliard Dec 24, 2024
e248f18
refactor: make test output and code less verbose
cbliard Dec 24, 2024
7d6487a
Add missing magic comment `# frozen_string_literal: true`
cbliard Jan 6, 2025
c21bb75
Move some tests at the `UpdateService` integration test level
cbliard Jan 6, 2025
94661a2
refactor: rewrite test using schedule helpers
cbliard Jan 7, 2025
4115cf4
refactor: replace condition term with guard clause
cbliard Jan 7, 2025
3414286
Make test assertion clearer
cbliard Jan 7, 2025
cd0a620
Move dates handling to `SetAttributesService`
cbliard Jan 7, 2025
c42d17d
[59539] Reschedule when switching parent to automatic mode
cbliard Jan 8, 2025
cc62435
refactor: make test execute faster
cbliard Jan 15, 2025
214352c
[59539] Correctly reschedule parent when it no longer has children
cbliard Jan 15, 2025
2474fb8
Remove leftovers
cbliard Jan 16, 2025
c764e9a
[59539] Reschedule successor when deleting a follows relation
cbliard Jan 16, 2025
ee699b6
Make WorkPackage factory use default factory for type if one is defined
cbliard Jan 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions app/contracts/work_packages/base_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,19 +101,19 @@ class BaseContract < ::ModelContract
attribute :schedule_manually
attribute :ignore_non_working_days,
writable: ->(*) {
!automatically_scheduled_parent?
leaf_or_manually_scheduled?
}

attribute :start_date,
writable: ->(*) {
!automatically_scheduled_parent?
leaf_or_manually_scheduled?
} do
validate_after_soonest_start(:start_date)
end

attribute :due_date,
writable: ->(*) {
!automatically_scheduled_parent?
leaf_or_manually_scheduled?
} do
validate_after_soonest_start(:due_date)
end
Expand Down Expand Up @@ -227,7 +227,10 @@ def assignable_assignees
attr_reader :can

def validate_after_soonest_start(date_attribute)
if !model.schedule_manually? && before_soonest_start?(date_attribute)
return if model.schedule_manually?
return if model.children.any?

if before_soonest_start?(date_attribute)
message = I18n.t("activerecord.errors.models.work_package.attributes.start_date.violates_relationships",
soonest_start: model.soonest_start)

Expand Down Expand Up @@ -627,8 +630,8 @@ def calculated_duration
@calculated_duration ||= WorkPackages::Shared::Days.for(model).duration(model.start_date, model.due_date)
end

def automatically_scheduled_parent?
!model.leaf? && !model.schedule_manually?
def leaf_or_manually_scheduled?
model.leaf? || model.schedule_manually?
end
end
end
23 changes: 21 additions & 2 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 Expand Up @@ -134,10 +140,23 @@ def label_for(work_package)
TYPES[relation_type] ? TYPES[relation_type][key] : :unknown
end

def predecessor = to
def predecessor_id = to_id
def successor = from
def successor_id = from_id

def predecessor_date
predecessor.due_date || predecessor.start_date
end

def successor_date
successor.start_date || successor.due_date
end

def successor_soonest_start
if follows? && (to.start_date || to.due_date)
if follows? && predecessor_date
days = WorkPackages::Shared::Days.for(from)
relation_start_date = (to.due_date || to.start_date) + 1.day
relation_start_date = predecessor_date + 1.day
days.soonest_working_day(relation_start_date, lag:)
end
end
Expand Down
114 changes: 87 additions & 27 deletions app/models/work_packages/scopes/covering_dates_and_days_of_week.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,61 @@ module WorkPackages::Scopes::CoveringDatesAndDaysOfWeek
# @param days_of_week number[] An array of the ISO days of the week to
# consider. 1 is Monday, 7 is Sunday.
def covering_dates_and_days_of_week(days_of_week: [], dates: [])
work_packages_periods_cte = work_packages_periods_cte_for_covering_work_packages
where_covers_periods(work_packages_periods_cte, days_of_week, dates)
end

def predecessors_needing_relations_rescheduling(days_of_week: [], dates: [])
work_packages_periods_cte = work_packages_periods_cte_for_predecessors_needing_relations_rescheduling
where_covers_periods(work_packages_periods_cte, days_of_week, dates)
end

private

def where_covers_periods(work_packages_periods_cte, days_of_week, dates)
days_of_week = Array(days_of_week)
dates = Array(dates)
return none if days_of_week.empty? && dates.empty?

where("id IN (#{query(days_of_week, dates)})")
end
covering_work_packages_query_sql = <<~SQL.squish
-- select work packages dates
WITH
-- cte returning a table with work package id, period start_date and end_date
#{work_packages_periods_cte},

private
-- All days between the start date of a work package and its due date
covered_dates AS (
SELECT
id,
generate_series(work_packages_periods.start_date,
work_packages_periods.end_date,
'1 day') AS date
FROM work_packages_periods
),

-- All days between the start date of a work package and its due date including the day of the week for each date
covered_dates_and_wday AS (
SELECT
id,
date,
EXTRACT(isodow FROM date) dow
FROM covered_dates
)

-- select id of work packages covering the given days
SELECT id FROM covered_dates_and_wday
WHERE dow IN (:days_of_week) OR date IN (:dates)
SQL

covering_work_packages_query_sql = sanitize_sql([covering_work_packages_query_sql, { days_of_week:, dates: }])

where("id IN (#{covering_work_packages_query_sql})")
end

def query(days_of_week, dates)
sql = <<~SQL.squish
-- select work packages dates with their followers dates
WITH work_packages_with_dates AS (
def work_packages_periods_cte_for_covering_work_packages
<<~SQL.squish
-- select work packages dates
work_packages_with_dates AS (
SELECT work_packages.id,
work_packages.start_date AS work_package_start_date,
work_packages.due_date AS work_package_due_date
Expand All @@ -68,30 +110,48 @@ def query(days_of_week, dates)
LEAST(work_package_start_date, work_package_due_date) AS start_date,
GREATEST(work_package_start_date, work_package_due_date) AS end_date
FROM work_packages_with_dates
)
SQL
end

def work_packages_periods_cte_for_predecessors_needing_relations_rescheduling
<<~SQL.squish
follows_relations
AS (SELECT
relations.id as id,
relations.to_id as pred_id,
relations.from_id as succ_id,
COALESCE(wp_pred.due_date, wp_pred.start_date) + INTERVAL '1 DAY' as pred_date,
COALESCE(wp_succ.start_date, wp_succ.due_date) - INTERVAL '1 DAY' as succ_date,
wp_succ.schedule_manually as succ_schedule_manually
FROM relations
LEFT JOIN work_packages wp_pred ON relations.to_id = wp_pred.id
LEFT JOIN work_packages wp_succ ON relations.from_id = wp_succ.id
WHERE relation_type = 'follows'
),
-- All days between the start date of a work package and its due date
covered_dates AS (
SELECT
id,
generate_series(work_packages_periods.start_date,
work_packages_periods.end_date,
'1 day') AS date
FROM work_packages_periods
-- select automatic follows relations. A relation is automatic if the
-- successor is scheduled automatically and both successor and
-- predecessor have dates
-- also excluded relations that have no duration (predecessor and successor "touch" each other)
automatic_follows_relations AS (
SELECT *
FROM follows_relations
WHERE succ_schedule_manually = false
AND pred_date IS NOT NULL
AND succ_date IS NOT NULL
AND pred_date <= succ_date
),
-- All days between the start date of a work package and its due date including the day of the week for each date
covered_dates_and_wday AS (
SELECT
id,
date,
EXTRACT(isodow FROM date) dow
FROM covered_dates
-- keep only the longest relation for each successor
-- get the predecessor id and the relation period for each relation
work_packages_periods AS (
SELECT DISTINCT ON (succ_id)
pred_id as id,
pred_date as start_date,
succ_date as end_date
FROM automatic_follows_relations
ORDER BY succ_id, pred_date ASC
)
-- select id of work packages covering the given days
SELECT id FROM covered_dates_and_wday
WHERE dow IN (:days_of_week) OR date IN (:dates)
SQL

sanitize_sql([sql, { days_of_week:, dates: }])
end
end
end
46 changes: 37 additions & 9 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 All @@ -51,6 +52,11 @@ module ForScheduling
# statement works, is that a work package is considered to be scheduled
# manually if *all* of its descendants are scheduled manually.
#
# One notable exception is if one of the manually scheduled children is
# the origin work package of the rescheduling. In this case, the parent is
# also subject to reschedule as the origin work package dates may have
# changed.
#
# For example in case of the hierarchy:
# A and B <- hierarchy (C is parent of both A and B) - C <- hierarchy - D
# * A and B are work packages
Expand Down Expand Up @@ -103,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 @@ -154,24 +160,44 @@ 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)",
.sanitize "(:id, false, false, true)",
id: wp.id
end.join(", ")

<<~SQL.squish
to_schedule (id, manually) AS (
-- 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)
),

SELECT * FROM (VALUES#{values}) AS t(id, manually, hierarchy_up)
-- 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)

UNION

SELECT
relations.from_id id,
(related_work_packages.schedule_manually OR COALESCE(descendants.manually, false)) manually,
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
FROM
to_schedule
JOIN LATERAL
Expand All @@ -196,12 +222,14 @@ def scheduling_paths_sql(work_packages)
FROM
work_package_hierarchies
WHERE
NOT to_schedule.manually
(NOT to_schedule.manually OR to_schedule.origin)
AND ((work_package_hierarchies.ancestor_id = to_schedule.id AND NOT to_schedule.hierarchy_up AND work_package_hierarchies.generations = 1)
OR (work_package_hierarchies.descendant_id = to_schedule.id AND work_package_hierarchies.generations > 0))
) 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
11 changes: 10 additions & 1 deletion app/seeders/demo_data/work_package_seeder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ def work_package_attributes
due_date:,
duration:,
ignore_non_working_days:,
schedule_manually:,
estimated_hours:
}
end
Expand All @@ -213,7 +214,7 @@ def initialize(attributes)

def start_date
days_ahead = attributes["start"] || 0
Time.zone.today.monday + days_ahead.days
Date.current.monday + days_ahead.days
end

def due_date
Expand All @@ -230,6 +231,14 @@ def ignore_non_working_days
.any? { |date| working_days.non_working?(date) }
end

def schedule_manually
if attributes["schedule_manually"].nil?
WorkPackage.column_defaults["schedule_manually"]
else
ActiveModel::Type::Boolean.new.cast(attributes["schedule_manually"])
end
end

def estimated_hours
attributes["estimated_hours"]&.to_i
end
Expand Down
Loading
Loading